Spring Security
做爲成熟且強大的安全框架,獲得許多大廠的青睞。而做爲先後端分離的SSO
方案,JWT
也在許多項目中應用。本文將介紹如何經過Spring Security
實現JWT
認證。java
用戶與服務器交互大概以下:git
JWT
,通常經過POST
方法把用戶名/密碼傳給server
;JWT
並返回;不正確則返回錯誤;JWT
後,在有效期
內均可以經過JWT
來訪問資源了,通常把JWT
放在請求頭;一次獲取,屢次使用;JWT
是否合法,合法則容許客戶端正常訪問,不合法則返回401。咱們把要整合的Spring Security
和JWT
加入到項目的依賴中去:github
<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>
JWT工具類起碼要具備如下功能:web
具體代碼以下:spring
@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
。數據庫
Filter
是Security
處理的關鍵,基本上都是經過Filter
來攔截請求的。首先從請求頭取出JWT
,而後校驗JWT
是否合法,若是合法則取出Authentication
保存在SecurityContextHolder
裏。若是不合法,則作異常處理。json
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
JWT
須要配置一個密鑰來加密,同時還要配置JWT
令牌的有效期。bash
@Configuration @ConfigurationProperties(prefix = "pkslow.jwt") public class JwtProperties { private String secretKey = "pkslow.key"; private long validityInMs = 3600_000; //getter and setter }
Spring Security
的整個框架仍是比較複雜的,簡化後大概以下圖所示:
它是經過一連串的Filter
來進行安全管理。細節這裏先不展開講。
這個配置也能夠理解爲是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); } }
一般在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
一樣能夠測試,這裏不列出來了。
代碼請查看:https://github.com/LarryDpk/pkslow-samples