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 = "https://www.jianshu.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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容