Springboot內建Spring Security實作JWT認證
我最新最全的文章都在 南瓜慢說 www.pkslow.com ,歡迎大家來喝茶!
1 簡介
Spring Security
作為成熟且強大的安全架構,得到許多大廠的青睐。而作為前後端分離的
SSO
方案,
JWT
也在許多項目中應用。本文将介紹如何通過
Spring Security
實作
JWT
認證。
使用者與伺服器互動大概如下:

- 用戶端擷取
,一般通過JWT
方法把使用者名/密碼傳給POST
;server
- 服務端接收到用戶端的請求後,會檢驗使用者名/密碼是否正确,如果正确則生成
并傳回;不正确則傳回錯誤;JWT
- 用戶端拿到
後,在JWT
内都可以通過有效期
來通路資源了,一般把JWT
放在請求頭;一次擷取,多次使用;JWT
- 服務端校驗
是否合法,合法則允許用戶端正常通路,不合法則傳回401。JWT
2 項目整合
我們把要整合的
Spring Security
和
JWT
加入到項目的依賴中去:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
2.1 JWT整合
2.1.1 JWT工具類
JWT工具類起碼要具有以下功能:
- 根據使用者資訊生成JWT;
- 校驗JWT是否合法,如是否被篡改、是否過期等;
- 從JWT中解析使用者資訊,如使用者名、權限等;
具體代碼如下:
@Component
public class JwtTokenProvider {
@Autowired JwtProperties jwtProperties;
@Autowired
private CustomUserDetailsService userDetailsService;
private String secretKey;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes());
}
public String createToken(String username, List<String> roles) {
Claims claims = Jwts.claims().setSubject(username);
claims.put("roles", roles);
Date now = new Date();
Date validity = new Date(now.getTime() + jwtProperties.getValidityInMs());
return Jwts.builder()//
.setClaims(claims)//
.setIssuedAt(now)//
.setExpiration(validity)//
.signWith(SignatureAlgorithm.HS256, secretKey)//
.compact();
}
public Authentication getAuthentication(String token) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(getUsername(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUsername(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
public String resolveToken(HttpServletRequest req) {
String bearerToken = req.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
if (claims.getBody().getExpiration().before(new Date())) {
return false;
}
return true;
} catch (JwtException | IllegalArgumentException e) {
throw new InvalidJwtAuthenticationException("Expired or invalid JWT token");
}
}
}
工具類還實作了另一個功能:從HTTP請求頭中擷取
JWT
。
2.1.2 Token處理的Filter
Filter
是
Security
處理的關鍵,基本上都是通過
Filter
來攔截請求的。首先從請求頭取出
JWT
,然後校驗
JWT
是否合法,如果合法則取出
Authentication
儲存在
SecurityContextHolder
裡。如果不合法,則做異常處理。
public class JwtTokenAuthenticationFilter extends GenericFilterBean {
private JwtTokenProvider jwtTokenProvider;
public JwtTokenAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
try {
String token = jwtTokenProvider.resolveToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication auth = jwtTokenProvider.getAuthentication(token);
if (auth != null) {
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
} catch (InvalidJwtAuthenticationException e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Invalid token");
response.getWriter().flush();
return;
}
filterChain.doFilter(req, res);
}
}
對于異常處理,使用
@ControllerAdvice
是不行的,應該這個是
Filter
,在這裡抛的異常還沒有到
DispatcherServlet
,無法處理。是以
Filter
要自己做異常處理:
catch (InvalidJwtAuthenticationException e) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter().write("Invalid token");
response.getWriter().flush();
return;
}
最後的
return;
不能省略,因為已經把要輸出的内容給
Response
了,沒有必要再往後傳遞,否則會報錯:
java.lang.IllegalStateException: getWriter() has already been called
2.1.3 JWT屬性
JWT
需要配置一個密鑰來加密,同時還要配置
JWT
令牌的有效期。
@Configuration
@ConfigurationProperties(prefix = "pkslow.jwt")
public class JwtProperties {
private String secretKey = "pkslow.key";
private long validityInMs = 3600_000;
//getter and setter
}
2.2 Spring Security整合
Spring Security
的整個架構還是比較複雜的,簡化後大概如下圖所示:
它是通過一連串的
Filter
來進行安全管理。細節這裡先不展開講。
2.2.1 WebSecurityConfigurerAdapter配置
這個配置也可以了解為是
FilterChain
的配置,可以不用了解,代碼很好懂它做了什麼:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
JwtTokenProvider jwtTokenProvider;
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/auth/login").permitAll()
.antMatchers(HttpMethod.GET, "/admin").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/user").hasRole("USER")
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfigurer(jwtTokenProvider));
}
}
這裡通過
HttpSecurity
配置了哪些請求需要什麼權限才可以通路。
-
用于登陸擷取/auth/login
,是以都能通路;JWT
-
隻有/admin
使用者才可以通路;ADMIN
-
隻有/user
使用者才可以通路。USER
而之前實作的
Filter
則在下面配置使用:
public class JwtSecurityConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private JwtTokenProvider jwtTokenProvider;
public JwtSecurityConfigurer(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public void configure(HttpSecurity http) throws Exception {
JwtTokenAuthenticationFilter customFilter = new JwtTokenAuthenticationFilter(jwtTokenProvider);
http.exceptionHandling()
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.and()
.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
2.2.2 使用者從哪來
通常在
Spring Security
的世界裡,都是通過實作
UserDetailsService
來擷取
UserDetails
的。
@Component
public class CustomUserDetailsService implements UserDetailsService {
private UserRepository users;
public CustomUserDetailsService(UserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return this.users.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("Username: " + username + " not found"));
}
}
對于
UserRepository
,可以從資料庫中讀取,或者其它使用者管理中心。為了友善,我使用
Map
放了兩個使用者:
@Repository
public class UserRepository {
private static final Map<String, User> allUsers = new HashMap<>();
@Autowired
private PasswordEncoder passwordEncoder;
@PostConstruct
protected void init() {
allUsers.put("pkslow", new User("pkslow", passwordEncoder.encode("123456"), Collections.singletonList("ROLE_ADMIN")));
allUsers.put("user", new User("user", passwordEncoder.encode("123456"), Collections.singletonList("ROLE_USER")));
}
public Optional<User> findByUsername(String username) {
return Optional.ofNullable(allUsers.get(username));
}
}
3 測試
完成代碼編寫後,我們來測試一下:
(1)無
JWT
通路,失敗
curl http://localhost:8080/admin
{"timestamp":"2021-02-06T05:45:06.385+0000","status":403,"error":"Forbidden","message":"Access Denied","path":"/admin"}
$ curl http://localhost:8080/user
{"timestamp":"2021-02-06T05:45:16.438+0000","status":403,"error":"Forbidden","message":"Access Denied","path":"/user"}
(2)admin擷取
JWT
,密碼錯誤則失敗,密碼正确則成功
$ curl http://localhost:8080/auth/login -X POST -d \'{"username":"pkslow","password":"xxxxxx"}\' -H \'Content-Type: application/json\'
{"timestamp":"2021-02-06T05:47:16.254+0000","status":403,"error":"Forbidden","message":"Access Denied","path":"/auth/login"}
$ curl http://localhost:8080/auth/login -X POST -d \'{"username":"pkslow","password":"123456"}\' -H \'Content-Type: application/json\'
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwa3Nsb3ciLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sImlhdCI6MTYxMjU5MDYxNCwiZXhwIjoxNjEyNTkxMjE0fQ.d4Gi50aaOsHHqpM0d8Mh1960otnZf7rlE3x6xSfakVo
(3)admin帶
JWT
通路
/admin
,成功;通路
/user
失敗
$ curl http://localhost:8080/admin -H \'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwa3Nsb3ciLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sImlhdCI6MTYxMjU5MDYxNCwiZXhwIjoxNjEyNTkxMjE0fQ.d4Gi50aaOsHHqpM0d8Mh1960otnZf7rlE3x6xSfakVo\'
you are admin
$ curl http://localhost:8080/user -H \'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwa3Nsb3ciLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sImlhdCI6MTYxMjU5MDYxNCwiZXhwIjoxNjEyNTkxMjE0fQ.d4Gi50aaOsHHqpM0d8Mh1960otnZf7rlE3x6xSfakVo\'
{"timestamp":"2021-02-06T05:51:23.099+0000","status":403,"error":"Forbidden","message":"Forbidden","path":"/user"}
(4)使用過期的
JWT
通路,失敗
$ curl http://localhost:8080/admin -H \'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJwa3Nsb3ciLCJyb2xlcyI6WyJST0xFX0FETUlOIl0sImlhdCI6MTYxMjU5MDQ0OSwiZXhwIjoxNjEyNTkwNTA5fQ.CSaubE4iJcYATbLmbb59aNFU1jNCwDFHUV3zIakPU64\'
Invalid token
對于使用者
user
同樣可以測試,這裡不列出來了。
4 總結
代碼請檢視:https://github.com/LarryDpk/pkslow-samples
歡迎關注微信公衆号<南瓜慢說>,将持續為你更新...
多讀書,多分享;多寫作,多整理。