SpringBoot 全家桶 | SpringSecurity + JWT 實現用戶登錄(兼容前后端未分離項目)

本文源碼:Gitee·點這里

前言

本篇主要講述 Spring Security 如何結合 JWT ,實現無狀態下用戶登錄,使其滿足前后端分離及應用集群化部署要求。

什么是有狀態

有狀態服務是服務端記錄客戶端會話信息,即Session信息。客戶端每次請求都會攜帶Session信息,服務端以此來識別客戶端身份。而 Session 保存在服務端內存中的,不支持集群化部署。

當然 Spring 也給出了解決方案,即使用特殊方式將 Session 序列化存入到數據庫中,以實現會話共享,滿足集群化部署要求。詳細案例參見《SpringBoot 全家桶 | SpringSession + Redis實現會話共享》

什么是無狀態

無狀態服務即服務端不保存任何客戶端會話信息,而是由客戶端每次請求必須攜帶自描述信息,服務端通過這些信息來識別客戶端身份。JWT便是無狀態的一種實現標準

什么是JWT

JSON Web Token (JWT)是一個開放標準(RFC 7519),它定義了一種緊湊的、自包含的方式,用于作為JSON對象在各方之間安全地傳輸信息。該信息可以被驗證和信任,因為它是數字簽名的。

JWT由三部分組成,這些部分由點(.)分隔,分別是:

  • Header 標頭
  • Payload 有效載體
  • Signature 簽名

因此,JWT通常通常是這樣子的:xxxxx.yyyyy.zzzzz

更多詳細內容參見官網:JSON Web Tokens

JWT如何工作

下圖顯示了如何獲取JWT并將其用于訪問API或資源:

client-credentials-grant
  1. 用程序或客戶端向授權服務器請求授權。
  2. 授權后,授權服務器會將訪問令牌返回給應用程序。
  3. 該應用程序使用訪問令牌來訪問受保護的資源(例如API)。

本案例使用框架

完整代碼 - 碼云

springboot-security-jwt

Spring Security 集成 JWT

pom文本引入io.jsonwebtoken.jjwt

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

增加JWT工具類

public class JwtUtil {

    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer";
    public static final long TTL = 2 * 60 * 60 * 1000;
    public static final int TTL_COOKIE = 2 * 60 * 60;
    private static final String SECRET_KEY = "http://www.lxweimin.com/u/1b5928185b73";
    private static final String AUTHORITIES = "authorities";

    /**
     * 生成 token
     *
     * @param username
     * @return
     */
    public static String generateToken(String username) {
        return generateToken(username, new ArrayList<>());
    }

    /**
     * 生成 token
     *
     * @param username
     * @param authorities
     * @return
     */
    public static String generateToken(String username, List<String> authorities) {
        return Jwts.builder()
                .setSubject(username) // 主題
                .claim(AUTHORITIES, authorities)
                .setIssuedAt(new Date()) // 發布時間
                .setExpiration(new Date(System.currentTimeMillis() + TTL)) // 到期時間
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 簽名
                .compact();
    }

    /**
     * 生成 token
     *
     * @param claims
     * @return
     */
    private static String generateToken(Claims claims) {
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + TTL)) // 到期時間
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 簽名
                .compact();
    }

    /**
     * 解析 token
     *
     * @param token
     * @return
     */
    public static Claims parseToken(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
    }

    /**
     * 獲取 username
     *
     * @param token
     * @return
     */
    public static String getUsername(String token) {
        return parseToken(token).getSubject();
    }

    /**
     * 獲取 username
     *
     * @param claims
     * @return
     */
    public static String getUsername(Claims claims) {
        return claims.getSubject();
    }

    /**
     * 是否過期
     *
     * @param token
     * @return
     */
    public static boolean isExpiration(String token) {
        return parseToken(token).getExpiration().before(new Date());
    }

    /**
     * 是否過期
     *
     * @param claims
     * @return
     */
    public static boolean isExpiration(Claims claims) {
        return claims.getExpiration().before(new Date());
    }

    /**
     * 獲取角色
     *
     * @param token
     * @return
     */
    public static List<String> getAuthorities(String token) {
        return parseToken(token).get(AUTHORITIES, List.class);
    }

    /**
     * 獲取角色
     *
     * @param claims
     * @return
     */
    public static List<String> getAuthorities(Claims claims) {
        return claims.get(AUTHORITIES, List.class);
    }

    /**
     * 刷新 token
     *
     * @param token
     * @return
     */
    public static String refreshToken(String token) {
        return generateToken(parseToken(token));
    }

}

Security配置類增加JWT配置

此處不詳細介紹Security的配置,而是把重點放在集成JWT上。(Security請參閱 《SpringBoot 全家桶 | SpringSecurity實戰》

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final Logger log = LoggerFactory.getLogger(this.getClass());

    @Resource
    private UserDao userDao;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/css/**", "/js/**", "/", "/index", "/loginPage").permitAll() // 無需認證
                .anyRequest().authenticated() // 其他請求都需要認證
        ;

        http.addFilter(new JwtAuthorizationFilter(authenticationManager()));

        http.formLogin() // 開啟登錄,如果沒有權限,就會跳轉到登錄頁
                .loginPage("/loginPage") // 自定義登錄頁,默認/login(get請求)
                .loginProcessingUrl("/login") // 登錄處理地址,默認/login(post請求)
                .usernameParameter("inputEmail") // 自定義username屬性名,默認username
                .passwordParameter("inputPassword") // 自定義password屬性名,默認password
                .successHandler(loginSuccessHandler())
        ;

        http.rememberMe() // 開啟記住我
                .rememberMeParameter("rememberMe") // 自定義rememberMe屬性名
        ;

        http.logout() // 開啟注銷
                .logoutUrl("/logout") // 注銷處理路徑,默認/logout
                .logoutSuccessUrl("/") // 注銷成功后跳轉路徑
                .deleteCookies(TOKEN_HEADER) // 刪除cookie
        ;

        http.csrf().disable(); // 禁止csrf

        http.sessionManagement() // session管理
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 無狀態,即不創建Session,也不使用SecurityContext獲取Session
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService())
                .passwordEncoder(passwordEncoder());
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> {
            xyz.zyl2020.securityjwt.entity.User user = userDao.findByUsernameOrEmail(username, username);
            if (user == null) {
                throw new UsernameNotFoundException("賬號或密碼錯誤!");
            }
            String[] roleCodeArray = user.getRoles().stream().map(Role::getCode).toArray(String[]::new);

            return User.withUsername(user.getUsername())
                    .password(user.getPassword())
                    .authorities(roleCodeArray)
                    .build();
        };
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationSuccessHandler loginSuccessHandler() {
        return (request, response, authentication) -> {
            List<String> authorities = new ArrayList<>();
            if (!CollectionUtils.isEmpty(authentication.getAuthorities())) {
                authorities.addAll(authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
            }
            String token = JwtUtil.generateToken(authentication.getName(), authorities);
            // 將token添加到header中
            response.setHeader(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
            // 將token添加到cookie中
            Cookie cookie = new Cookie(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
            cookie.setPath("/");
            cookie.setMaxAge(JwtUtil.TTL_COOKIE);
            response.addCookie(cookie);
            log.info("登錄成功,username: {}, token: {}", authentication.getName(), token);
        };
    }
}

  • http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)Session管理配置為無狀態,這樣Security便不會創建Session
  • loginSuccessHandler() 登錄成功處理器,用于登錄成功后,使用生成JWT工具類生成token,并將token添加到headercookie中(添加到cookie的目標是為了兼容未做前后端分離的應用)給客戶端響應。
  • http.addFilter(new JwtAuthorizationFilter(authenticationManager())) 增加 JWT 授權過濾器,后面詳細介紹
  • http.logout().deleteCookies(TOKEN_HEADER) 用戶注銷后刪除token

JWT 授權過濾器

添加此過濾器的目的是客戶端每次請求時,驗證其令牌是否合法

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        String headerToken = "";
        // 從cookie中獲取token
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (JwtUtil.TOKEN_HEADER.equals(cookie.getName()) && StringUtils.isNotBlank(cookie.getValue())) {
                    headerToken = cookie.getValue();
                    break;
                }
            }
        }
        // 從header中獲取token
        if (StringUtils.isBlank(headerToken) && StringUtils.isNotBlank(request.getHeader(JwtUtil.TOKEN_HEADER))) {
            headerToken = request.getHeader(JwtUtil.TOKEN_HEADER);
        }
        // 從參數中獲取token
        if (StringUtils.isBlank(headerToken) && StringUtils.isNotBlank(request.getParameter(JwtUtil.TOKEN_HEADER))) {
            headerToken = request.getParameter(JwtUtil.TOKEN_HEADER);
        }

        // 校驗token頭
        if (StringUtils.isBlank(headerToken) || !headerToken.startsWith(JwtUtil.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 解析token
        String token = headerToken.substring(JwtUtil.TOKEN_PREFIX.length());
        Claims claims = JwtUtil.parseToken(token);
        // 校驗token是否過期
        if (JwtUtil.isExpiration(claims)) {
            chain.doFilter(request, response);
            return;
        }

        String username = JwtUtil.getUsername(claims);
        if (StringUtils.isBlank(username)) {
            chain.doFilter(request, response);
            return;
        }
        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String authority : JwtUtil.getAuthorities(claims)) {
            authorities.add(new SimpleGrantedAuthority(authority));
        }
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(username, null, authorities));

        refreshToken(token, response);
        super.doFilterInternal(request, response, chain);
    }

    /**
     * 刷新token
     *
     * @param token
     * @param response
     */
    private void refreshToken(String token, HttpServletResponse response) {
        token = JwtUtil.refreshToken(token);
        // 將token添加到header中
        response.setHeader(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
        // 將token添加到cookie中
        Cookie cookie = new Cookie(TOKEN_HEADER, JwtUtil.TOKEN_PREFIX + token);
        cookie.setPath("/");
        cookie.setMaxAge(JwtUtil.TTL_COOKIE);
        response.addCookie(cookie);
    }
}

獲取客戶端令牌兼容了三種方式:

  • cookie中獲取令牌,一般用于兼容未做前后端分離的應用
  • header中獲取
  • 從請求參數中獲取

令牌驗證通過后,解析其用戶名和權限,并將其添加至Security上下文中。

最后刷新令牌,以保持用戶長時間活動時其令牌不會過期。

參考

  1. Spring Security
  2. thymeleaf-extras-springsecurity
  3. Thymeleaf + Spring Security integration basics
  4. Bootstrap4
  5. JWT
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容