天天看點

Springboot內建Spring Security實作JWT認證 - 南瓜慢說

Springboot內建Spring Security實作JWT認證

我最新最全的文章都在 南瓜慢說 www.pkslow.com ,歡迎大家來喝茶!

1 簡介

Spring Security

作為成熟且強大的安全架構,得到許多大廠的青睐。而作為前後端分離的

SSO

方案,

JWT

也在許多項目中應用。本文将介紹如何通過

Spring Security

實作

JWT

認證。

使用者與伺服器互動大概如下:

Springboot內建Spring Security實作JWT認證 - 南瓜慢說
  1. 用戶端擷取

    JWT

    ,一般通過

    POST

    方法把使用者名/密碼傳給

    server

  2. 服務端接收到用戶端的請求後,會檢驗使用者名/密碼是否正确,如果正确則生成

    JWT

    并傳回;不正确則傳回錯誤;
  3. 用戶端拿到

    JWT

    後,在

    有效期

    内都可以通過

    JWT

    來通路資源了,一般把

    JWT

    放在請求頭;一次擷取,多次使用;
  4. 服務端校驗

    JWT

    是否合法,合法則允許用戶端正常通路,不合法則傳回401。

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

的整個架構還是比較複雜的,簡化後大概如下圖所示:

Springboot內建Spring Security實作JWT認證 - 南瓜慢說

它是通過一連串的

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

歡迎關注微信公衆号<南瓜慢說>,将持續為你更新...

多讀書,多分享;多寫作,多整理。