前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Spring Security----JWT详解

Spring Security----JWT详解

作者头像
大忽悠爱学习
发布2021-12-07 18:48:48
2.4K0
发布2021-12-07 18:48:48
举报
文章被收录于专栏:c++与qt学习c++与qt学习

Spring Security----JWT详解


基于Session的应用开发的缺陷

在我们传统的B\S应用开发方式中,都是使用session进行状态管理的,比如说:保存登录、用户、权限等状态信息。这种方式的原理大致如下:

在这里插入图片描述
在这里插入图片描述
  • 用户登陆之后,将状态信息保存到session里面。服务端自动维护sessionid,即将sessionid写入cookie。
  • cookie随着HTTP响应,被自动保存到浏览器端。
  • 当用户再次发送HTTP请求,sessionid随着cookies被带回服务器端
  • 服务器端根据sessionid,可以找回该用户之前保存在session里面的数据。

当然,这整个过程中,cookies和sessionid都是服务端和浏览器端自动维护的。所以从编码层面是感知不到的,程序员只能感知到session数据的存取。但是,这种方式在有些情况下,是不适用的。

  • 比如:非浏览器的客户端、手机移动端等等,因为他们没有浏览器自动维护cookies的功能。
  • 比如:集群应用,同一个应用部署甲、乙、丙三个主机上,实现负载均衡应用,其中一个挂掉了其他的还能负载工作。要知道session是保存在服务器内存里面的,三个主机一定是不同的内存。那么你登录的时候访问甲,而获取接口数据的时候访问乙,就无法保证session的唯一性和共享性。

当然以上的这些情况我们都有方案(如redis共享session等),可以继续使用session来保存状态。但是还有另外一种做法就是不用session了,即开发一个无状态的应用,JWT就是这样的一种方案。

JWT是什么?

JWT是一个加密后的接口访问密码,并且该密码里面包含用户名信息。这样既可以知道你是谁?又可以知道你是否可以访问应用?

在这里插入图片描述
在这里插入图片描述
  • 首先,客户端需要向服务端申请JWT令牌,这个过程通常是登录功能。即:由用户名和密码换取JWT令牌。
  • 当你访问系统其他的接口时,在HTTP的header中携带JWT令牌。header的名称可以自定义,前后端对应上即可。
  • 服务端解签验证JWT中的用户标识,根据用户标识从数据库中加载访问权限、用户信息等状态信息。

JWT结构分析

下图是我用在线的JWT解码工具,解码时候的截图。注意我这里用的是解码,不是解密。

在这里插入图片描述
在这里插入图片描述

从图中,我们可以看到JWT分为三个部分:

  • Header,这个部分通常是用来说明JWT使用的算法信息
  • payload,这个部分通常用于携带一些自定义的状态附加信息(重要的是用户标识)。但是注意这部分是可以明文解码的,所以注意是用户标识,而不应该是用户名或者其他用户信息。
  • signature,这部分是对前两部分数据的签名,防止前两部分数据被篡改。这里需要指定一个密钥secret,进行签名和解签。

JWT安全么?

很多的朋友看到上面的这个解码文件,就会生出一个疑问?你都把JWT给解析了,而且JWT又这么的被大家广泛熟知,它还安全么?我用一个简单的道理说明一下:

  • JWT就像是一把钥匙,用来开你家里的锁。用户把钥匙一旦丢了,家自然是不安全的。其实和使用session管理状态是一样的,一旦网络或浏览器被劫持了,肯定不安全。
  • signature通常被叫做签名,而不是密码。比如:天王盖地虎是签名,宝塔镇河妖就被用来解签。字你全都认识,但是暗号只有知道的人才对得上。当然JWT中的暗号secret不会设计的像诗词一样简单。
  • JWT服务端也保存了一把钥匙,就是暗号secret。用来数据的签名和解签,secret一旦丢失,所有用户都是不安全的。所以对于IT人员,更重要的是保护secret的安全。

如何加强JWT的安全性?

  • 避免网络劫持,因为使用HTTP的header传递JWT,所以使用HTTPS传输更加安全。这样在网络层面避免了JWT的泄露。
  • secret是存放在服务器端的,所以只要应用服务器不被攻破,理论上JWT是安全的。因此要保证服务器的安全。
  • 那么有没有JWT加密算法被攻破的可能?当然有。但是对于JWT常用的算法要想攻破,目前已知的方法只能是暴力破解,白话说就是"试密码"。所以要定期更换secret并且保正secret的复杂度,等破解结果出来了,你的secret已经换了。
  • 话说回来,如果你的服务器、或者你团队的内部人员出现漏洞,同样没有一种协议和算法是安全的。

JWT结合Spring Security认证细节说明

在这里插入图片描述
在这里插入图片描述
  • 当客户端发送“/authentication”请求的时候,实际上是请求JwtAuthenticationController。该Controller的功能是:一是用户登录功能的实现,二是如果登录成功,生成JWT令牌。在使用JWT的情况下,这个类需要我们自己来实现。
  • 具体到用户登录,就需要结合Spring Security实现。通过向Spring Security提供的AuthenticationManager的authenticate()方法传递用户名密码,由spring Security帮我们实现用户登录认证功能。
  • 如果登陆成功,我们就要为该用户生成JWT令牌了。通常此时我们需要使用UserDetailsService的loadUserByUsername方法加载用户信息,然后根据信息生成JWT令牌,JWT令牌生成之后返回给客户端。
  • 另外,我们需要写一个工具类JwtTokenUtil,该工具类的主要功能就是根据用户信息生成JWT,解签JWT获取用户信息,校验令牌是否过期,刷新令牌等。

接口鉴权细节

当客户端获取到JWT之后,他就可以使用JWT请求接口资源服务了。大家可以看到在“授权流程细节”的时序图中,有一个Filter过滤器我们没有讲到,其实它和授权认证的流程关系不大,它是用来进行接口鉴权的。因为授权认证就只有一个接口即可,但是服务资源接口却有很多,所以我们不可能在每一个Controller方法中都进行鉴权,所以在到达Controller之前通过Filter过滤器进行JWT解签和权限校验。

在这里插入图片描述
在这里插入图片描述

假如我们有一个接口资源“/hello”定义在HelloWorldcontroller中,鉴权流程是如何进行的?请结合上图进行理解:

  • 当客户端请求“/hello”资源的时候,他应该在HTTP请求的Header带上JWT字符串。Header的名称前后端服务自己定义,但是要统一。
  • 服务端需要自定义JwtRequestFilter,拦截HTTP请求,并判断请求Header中是否有JWT令牌。如果没有,就执行后续的过滤器。因为Spring Security是有完整的鉴权体系的,你没赋权该请求就是非法的,后续的过滤器链会将该请求拦截,最终返回无权限访问的结果。
  • 如果在HTTP中解析到JWT令牌,就调用JwtTokenUtil对令牌的有效期及合法性进行判定。如果是伪造的或者过期的,同样返回无权限访问的结果
  • 如果JWT令牌在有效期内并且校验通过,我们仍然要通过UserDetailsService加载该用户的权限信息,并将这些信息交给Spring Security。只有这样,该请求才能顺利通过Spring Security一系列过滤器的关卡,顺利到达HelloWorldcontroller并访问“/hello”接口。

其他的细节问题

  • 一旦发现用户的JWT令牌被劫持,或者被个人泄露该怎么办?JWT令牌有一个缺点就是一旦发放,在有效期内都是可用的,那怎么回收令牌?我们可以通过设置黑名单ip、用户,或者为每一个用户JWT令牌使用一个secret密钥,可以通过修改secret密钥让该用户的JWT令牌失效。
  • 如何刷新令牌?为了提高安全性,我们的令牌有效期通常时间不会太长。那么,我们不希望用户正在使用app的时候令牌过期了,用户必须重新登陆,很影响用户体验。这怎么办?这就需要在客户端根据业务选择合适的时机或者定时的刷新JWT令牌。所谓的刷新令牌就是用有效期内,用旧的合法的JWT换取新的JWT。

编码实现JWT认证鉴权

环境准备工作

  • 建立Spring Boot项目并集成了Spring Security,项目可以正常启动
  • 通过controller写一个HTTP的GET方法服务接口,比如:“/hello”
  • 实现最基本的动态数据验证及权限分配,即实现UserDetailsService接口和UserDetails接口。这两个接口都是向SpringSecurity提供用户、角色、权限等校验信息的接口
  • 如果你学习过Spring Security的formLogin登录模式,请将HttpSecurity配置中的formLogin()配置段全部去掉。因为JWT完全使用JSON接口,没有from表单提交。
  • HttpSecurity配置中一定要加上csrf().disable(),即暂时关掉跨站攻击CSRF的防御。这样是不安全的,我们后续章节再做处理。

开发JWT工具类

通过maven坐标引入JWT工具包jjwt

代码语言:javascript
复制
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

在application.yml中加入如下自定义一些关于JWT的配置

代码语言:javascript
复制
jwt:
  header: JWTHeaderName  #在请求头中的名字
  secret: aabbccdd         #秘钥
  expiration: 3600000     #过期时间,单位毫秒
  • 其中header是携带JWT令牌的HTTP的Header的名称。虽然我这里叫做JWTHeaderName,但是在实际生产中可读性越差越安全。
  • secret是用来为JWT基础信息加密和解密的密钥。虽然我在这里在配置文件写死了,但是在实际生产中通常不直接写在配置文件里面。而是通过应用的启动参数传递,并且需要定期修改。
  • expiration是JWT令牌的有效时间。

写一个Spring Boot配置自动加载的工具类。

代码语言:javascript
复制
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtTokenUtil {

    private String secret;
    private Long expiration;
    private String header;


    /**
     * 生成token令牌
     *
     * @param userDetails 用户
     * @return 令token牌
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>(2);
        claims.put("sub", userDetails.getUsername());
        claims.put("created", new Date());

        return generateToken(claims);
    }

    /**
     * 从claims生成令牌,如果看不懂就看谁调用它
     *
     * @param claims 数据声明
     * @return 令牌
     */
    private String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + expiration);
        return Jwts.builder().setClaims(claims)
                .setExpiration(expirationDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    
    
    /**
     * 从令牌中获取用户名
     *
     * @param token 令牌
     * @return 用户名
     */
    public String getUsernameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }

    /**
     * 判断令牌是否过期
     *
     * @param token 令牌
     * @return 是否过期
     */
    public Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 刷新令牌
     *
     * @param token 原令牌
     * @return 新令牌
     */
    public String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);
            claims.put("created", new Date());
            refreshedToken = generateToken(claims);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 验证令牌
     *
     * @param token       令牌
     * @param userDetails 用户
     * @return 是否有效
     */
    public Boolean validateToken(String token, UserDetails userDetails) {

        String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    
    /**
     * 从令牌中获取数据声明,如果看不懂就看谁调用它
     *
     * @param token 令牌
     * @return 数据声明
     */
    private Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

}

上面的代码就是使用io.jsonwebtoken.jjwt提供的方法开发JWT令牌生成、刷新的工具类。


开发登录接口(获取Token的接口)

  • "/authentication"接口用于登录验证,并且生成JWT返回给客户端
  • "/refreshtoken"接口用于刷新JWT,更新JWT令牌的有效期
代码语言:javascript
复制
@RestController
public class JwtAuthController {

    @Resource
    private JwtAuthService jwtAuthService;

    @PostMapping(value = "/authentication")
    public String login(@RequestBody Map<String, String> map) {
        String username = map.get("username");
        String password = map.get("password");
        if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
            return "用户名密码不能为空";
        }
        try{
            return "token= "+jwtAuthService.login(username, password);
        }catch(CustomException e){
            return "用户名或密码错误";
        }
    }

    @PostMapping(value = "/refreshtoken")
    //${jwt.header}:参考value注解
    public String refresh(@RequestHeader("${jwt.header}") String token) {
        return "刷新后的token= "+jwtAuthService.refreshToken(token);
    }

}

核心的token业务逻辑写在JwtAuthService 中

  • login方法中首先使用用户名、密码进行登录验证。如果验证失败抛出AuthenticationException异常。如果验证成功,程序继续向下走,生成JWT响应给前端
  • refreshToken方法只有在JWT token没有过期的情况下才能刷新,过期了就不能刷新了。需要重新登录。
代码语言:javascript
复制
@Service
public class JwtAuthService {

    @Resource
    private AuthenticationManager authenticationManager;
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private JwtTokenUtil jwtTokenUtil;

    public String login(String username, String password) throws CustomException {
        //使用用户名密码进行登录验证
        UsernamePasswordAuthenticationToken upToken =
                new UsernamePasswordAuthenticationToken( username, password );
        Authentication authentication = authenticationManager.authenticate(upToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        //生成JWT
        UserDetails userDetails = userDetailsService.loadUserByUsername( username );
        return jwtTokenUtil.generateToken(userDetails);
    }

    public String refreshToken(String oldToken) {
        if (!jwtTokenUtil.isTokenExpired(oldToken)) {
            return jwtTokenUtil.refreshToken(oldToken);
        }
        return null;
    }
}

因为使用到了AuthenticationManager ,所以在继承WebSecurityConfigurerAdapter的SpringSecurity配置实现类中,将AuthenticationManager 声明为一个Bean。并将"/authentication"和 "/refreshtoken"开放访问权限,如何开放访问权限,我们之前的文章已经讲过了。

代码语言:javascript
复制
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
    return super.authenticationManagerBean();
}

接口访问鉴权过滤器

当用户第一次登陆之后,我们将JWT令牌返回给了客户端,客户端应该将该令牌保存起来。在进行接口请求的时候,将令牌带上,放到HTTP的header里面,header的名字要和jwt.header的配置一致,这样服务端才能解析到。下面我们定义一个拦截器:

  • 拦截接口请求,从请求request获取token,从token中解析得到用户名
  • 然后通过UserDetailsService获得系统用户(从数据库、或其他其存储介质)
  • 根据用户信息和JWT令牌,验证系统用户与用户输入的一致性,并判断JWT是否过期。如果没有过期,至此表明了该用户的确是该系统的用户。
  • 但是,你是系统用户不代表你可以访问所有的接口。所以需要构造UsernamePasswordAuthenticationToken传递用户、权限信息,并将这些信息通过authentication告知Spring Security。Spring Security会以此判断你的接口访问权限。
代码语言:javascript
复制
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    MyUserDetailService myUserDetailsService;

    @Resource
    JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException
    {
        //从请求头中获取token
        String jwtToken = request.getHeader(jwtTokenUtil.getHeader());
        //token判空
        if(jwtToken != null && StringUtils.isNoneEmpty(jwtToken)){
            //获取用户姓名
            String username = jwtTokenUtil.getUsernameFromToken(jwtToken);

            //如果可以正确的从JWT中提取用户信息,并且该用户未被授权
            if(username != null &&
                    SecurityContextHolder.getContext().getAuthentication() == null){

                UserDetails userDetails = myUserDetailsService.loadUserByUsername(username);
                 //检验token的合法性
                if(jwtTokenUtil.validateToken(jwtToken,userDetails)){
                    //给使用该JWT令牌的用户进行授权
                    UsernamePasswordAuthenticationToken authenticationToken
                            = new UsernamePasswordAuthenticationToken(userDetails,null,
                                                                userDetails.getAuthorities());
                  //放入spring security的上下文环境中,表示认证通过
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);

                }
            }
        }
        //过滤器链往后继续执行
        filterChain.doFilter(request,response);
    }
}

在spring Security的配置类(即WebSecurityConfigurerAdapter实现类的configure(HttpSecurity http)配置方法中,加入如下配置:

代码语言:javascript
复制
 //Spring Security不会创建或使用任何session。适合于接口型的无状态应用(前后端分离无状态应用),这种方式节省内存资源
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
      //自定义过滤器配置
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
  • 因为我们使用了JWT,表明了我们的应用是一个前后端分离的应用,所以我们可以开启STATELESS禁止使用session。当然这并不绝对,前后端分离的应用通过一些办法也是可以使用session的,这不是本文的核心内容不做赘述。
  • 将我们的自定义jwtAuthenticationTokenFilter,加载到UsernamePasswordAuthenticationFilter的前面。

测试

测试登录接口,即:获取token的接口。输入正确的用户名、密码即可获取token。

在这里插入图片描述
在这里插入图片描述

下面我们访问一个我们定义的简单的接口“/hello”,但是不传递JWT令牌,结果是禁止访问。当我们将上一步返回的token,传递到header中,就能正常响应hello的接口结果。

在这里插入图片描述
在这里插入图片描述

JWT集群应用方案

回顾JWT授权与验证流程

在我们之前实现的JWT应用中,登录认证的Controller和令牌验证的Filter是在同一个应用中的。

在这里插入图片描述
在这里插入图片描述

要想使用JWT访问资源需要

  • 先使用用户名和密码,去Controller换取JWT令牌
  • 然后才能进行资源的访问,资源接口的前端由一个"JWT验证Filter"负责校验令牌和授权访问。

集群应用

那我们可以思考一个问题,如果上面的应用部署两份形成集群应用,也就是“应用A”和“应用B”,代码是同一套代码。如果认证过程是在“应用A”获取的JWT令牌,可以访问“应用B”的接口资源么?(如下图)

在这里插入图片描述
在这里插入图片描述

答案是:可以。因为两个应用中没有在内存(session)中保存中保存任何的状态信息,所有的信息都是去数据库里面现加载的。所以只要这两个应用,使用同一个数据库、同一套授权数据、同一个用于签名和解签的secret。就可以实现“应用A”的认证、在“应用B”中被承认。 那么另外一个问题来了,对于上面的集群应用,“应用A”和“应用B”实际上是一份代码部署两份。如果“应用A”和“应用B”是真正的两套代码的部署结果呢?答案仍然是可以。前提是你的认证Controller代码和鉴权Filter代码的实现逻辑是一样的、校验规则是一样的。使用同一个数据库、同一套授权数据、同一个用于签名和解签的secret。所以JWT服务端应用可以很容易的扩展。


独立的授权服务

基于JWT的这种无状态的灵活性,它很容易实现应用横向扩展。只要具备以下条件任何JWT的应用都可以整合为一个应用集群。

  • 认证Controller代码统一
  • 鉴权Filter代码统一、校验规则是一样的。
  • 使用同一套授权数据
  • 同一个用于签名和解签的secret。

基于这个条件前提,我们完全可以把认证Controller代码单独抽取出来,形成“认证服务器”。如下图所示:

在这里插入图片描述
在这里插入图片描述

或者我们还可以进一步把所有的Jwt验证鉴权Filter代码单独抽取出来,形成“服务网关”,放在接口资源的前端。当然“服务网关”的功能不只是鉴权、还要有请求转发的功能。

最后剩下的一系列的“接口资源”,实际上就是我们常说的“资源服务器”。


配置类代码

代码语言:javascript
复制
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
    private UserDetailsService userDetailsService;
    private PasswordEncoder passwordEncoder;
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    SecurityConfig(MyUserDetailService myUserDetailService,JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter)
    {
        this.userDetailsService=myUserDetailService;
        this.jwtAuthenticationTokenFilter=jwtAuthenticationTokenFilter;
    }

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    //加密密码
    @Bean
    public PasswordEncoder passwordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        this.passwordEncoder=passwordEncoder();
        ((MyUserDetailService)userDetailsService).setPasswordEncoder(passwordEncoder);

    auth.userDetailsService(userDetailsService)
            .passwordEncoder(passwordEncoder);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置hello请求的访问权限
        http.authorizeRequests().antMatchers("/hello").hasAuthority("hello");
        //放行登录和刷新令牌的请求
        http.authorizeRequests().antMatchers("/authentication").permitAll().anyRequest().authenticated();
        //Spring Security不会创建或使用任何session。适合于接口型的无状态应用(前后端分离无状态应用),这种方式节省内存资源
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
        //自定义过滤器配置
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        //关闭csrf防护
        http.csrf().disable();
    }
}
本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-11-21 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Spring Security----JWT详解
  • 基于Session的应用开发的缺陷
  • JWT是什么?
  • JWT结构分析
  • JWT安全么?
  • JWT结合Spring Security认证细节说明
  • 接口鉴权细节
  • 其他的细节问题
  • 编码实现JWT认证鉴权
    • 环境准备工作
    • 开发JWT工具类
      • 开发登录接口(获取Token的接口)
        • 接口访问鉴权过滤器
          • 测试
          • JWT集群应用方案
            • 回顾JWT授权与验证流程
              • 集群应用
                • 独立的授权服务
                  • 配置类代码
                  相关产品与服务
                  负载均衡
                  负载均衡(Cloud Load Balancer,CLB)提供安全快捷的流量分发服务,访问流量经由 CLB 可以自动分配到云中的多台后端服务器上,扩展系统的服务能力并消除单点故障。负载均衡支持亿级连接和千万级并发,可轻松应对大流量访问,满足业务需求。
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档