Spring Security+JWT简述

Posted IT自习小空间

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security+JWT简述相关的知识,希望对你有一定的参考价值。

目录

一. 什么是Spring Security

Spring Security是Spring家族的一个安全管理框架, 相比于另一个安全框架Shiro, 它具有更丰富的功能。一般中大型项目都是使用SpringSecurity做安全框架, 而Shiro上手比较简单

spring security 的核心功能:

  • 认证(你是谁): 只有你的用户名或密码正确才能访问某些资源

  • 授权(你能干嘛): 当前用户具有哪些功能, 将资源进行划分, 如在公司中分为普通资料和高级资料, 只有经理用户以上才能访文高级资料, 其他人只能拥有访问普通资料的权限。

1. 登陆校验的流程

2. SpringSecurity基础案例

首先创建一个Springboot的项目

添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

创建一个controller类

@RestController
public class TestController 

    @GetMapping("/hello")
    public String hello() 
        return "hello";
    

启动项目访问http://localhost:8080/login, 发现页面并没有hello字符, 下图是SpringSeurity默认的登陆界面, 默认用户名为user, 密码为启动项目时在输出框中的内容


在实际项目中, 显然不能使用默认的登陆界面, 所以我们需要自定义登陆认证和授权

二. Spring Security原理流程

SpringSecurity底层实现是一系列过滤器链

默认自动配置的过滤器

过滤器作用
WebAsyncManagerIntegrationFilter将WebAsyncManger与SpringSecurity上下文进行集成
SecurityContextPersistenceFilter在处理请求之前, 将安全信息加载到SecurityContextHolder中
HeaderWriterFilter处理头信息假如响应中
CsrfFilter处理CSRF攻击
LogoutFilter处理注销登录
UsernamePasswordAuthenticationFilter处理表单登录
DefaultLoginPageGeneratingFilter配置默认登录页面
DefaultLogoutPageGeneratingFilter配置默认注销页面
BasicAuthenticationFilter处理HttpBasic登录
RequestCacheAwareFilter处理请求缓存
SecurityContextHolderAwareRequestFilter包装原始请求
AnonymousAuthenticationFilter配置匿名认证
SessionManagementFilter处理session并发问题
ExceptionTranslationFilter处理认证/授权中的异常
FilterSecurityInterceptor处理授权相关

下图是主要的过滤器

上图只画出了核心的过滤器

UsernamePasswordAuthenticationFilter: 负责处理登陆页面填写的用户名和密码的登陆请求

ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException异常

FilterSecurityInterceptor: 负责权限校验的过滤器

1. 大致流程


(1) 下面是UsernamePasswordAuthenticationFilter中的attemptAuthentication方法, 该方法会将前端发送的用户名和密码封装为UsernamePasswordAuthenticationToken对象, 该对象是Authentication对象的实现类

注意: attemptAuthentication方法主要处理视图表单认证, 现今都是前后端分离项目导致不能使用该方法进行拦截, 所以我们需要自己实现一个过滤器覆盖或者在UsernamePasswordAuthenticationFilter之前做用户名和密码拦截处理.

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException 
    if (this.postOnly && !request.getMethod().equals("POST")) 
        throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
     else 
        String username = this.obtainUsername(request);
        String password = this.obtainPassword(request);
        if (username == null) 
            username = "";
        

        if (password == null) 
            password = "";
        

        username = username.trim();
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        this.setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    

(2) 返回getAuthenticationManager.authenticate(authRequest), 将未认证的Authentication对象传入AuthenticationManager , 进入authenticate方法我们看到AuthenticationManager是一个接口, 该接口主要做认证管理, 它的默认实现类是ProviderManager

public interface AuthenticationManager 
    Authentication authenticate(Authentication var1) throws AuthenticationException;

(3) 在SpringSecurity中, 在项目中支持多种不同方式的认证方式, 不同的认证方式对应不同的AuthenticationProvider, 多个AuthenticationProvider 组成一个列表, 这个列表由ProviderManager代理, 在ProviderManager中遍历列表中的每一个AuthenticationProvider进行认证

public Authentication authenticate(Authentication authentication) throws AuthenticationException 
    Class<? extends Authentication> toTest = authentication.getClass();
    AuthenticationException lastException = null;
    AuthenticationException parentException = null;
    Authentication result = null;
    Authentication parentResult = null;
    boolean debug = logger.isDebugEnabled();
    // 迭代遍历认证列表
    Iterator var8 = this.getProviders().iterator();

    while(var8.hasNext()) 
    	// 取出当前认证
        AuthenticationProvider provider = (AuthenticationProvider)var8.next();
        // 当前认证是否支持当前的用户名和密码信息
        if (provider.supports(toTest)) 
            if (debug) 
                logger.debug("Authentication attempt using " + provider.getClass().getName());
            

            try 
            	// 开始做认证处理
                result = provider.authenticate(authentication);
                if (result != null) 
                	// 认证成功时候返回
                    this.copyDetails(authentication, result);
                    break;
                
             catch (InternalAuthenticationServiceException | AccountStatusException var13) 
                this.prepareException(var13, authentication);
                throw var13;
             catch (AuthenticationException var14) 
                lastException = var14;
            
        
    

	// 不支持当前认证并且parent支持该认证
    if (result == null && this.parent != null) 
        try 
            result = parentResult = this.parent.authenticate(authentication);
         catch (ProviderNotFoundException var11) 
         catch (AuthenticationException var12) 
            parentException = var12;
            lastException = var12;
        
    

    if (result != null) 
        if (this.eraseCredentialsAfterAuthentication && result instanceof CredentialsContainer) 
            ((CredentialsContainer)result).eraseCredentials();
        

        if (parentResult == null) 
            this.eventPublisher.publishAuthenticationSuccess(result);
        

        return result;
     else 
        if (lastException == null) 
            lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound", new Object[]toTest.getName(), "No AuthenticationProvider found for 0"));
        

        if (parentException == null) 
            this.prepareException((AuthenticationException)lastException, authentication);
        

        throw lastException;
    

拓展:
ProviderManager可以配置一个AuthenticationManager作为parent, 当ProviderManager认证失败后, 可以进入parent中再次进行认证, 通常由ProviderManager来充当parent的角色, 即ProviderManagerProviderManager的parent
ProviderManager可以有多个, 而多个ProviderManager共用一个parent

(4) 当前AuthenticationProvider支持认证时, 会进入AuthenticationProviderauthenticate方法, 而AuthenticationProvider是一个接口, 它的实现类是AbstractUserDetailsAuthenticationProvider

public Authentication authenticate(Authentication authentication) throws AuthenticationException 
    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> 
        return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
    );
    // 获取当前authentication的信息
    String username = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
    boolean cacheWasUsed = true;
    // 在缓存中查看username
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) 
        cacheWasUsed = false;

        try 
        	// 调用retrieveUser方法
            user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
         catch (UsernameNotFoundException var6) 
            this.logger.debug("User '" + username + "' not found");
            if (this.hideUserNotFoundExceptions) 
                throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
            

            throw var6;
        

        Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
    

    try 
        this.preAuthenticationChecks.check(user);
        this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
     catch (AuthenticationException var7) 
        if (!cacheWasUsed) 
            throw var7;
        

        cacheWasUsed = false;
        user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
        this.preAuthenticationChecks.check(user);
        // 密码的加密处理
        this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    

    this.postAuthenticationChecks.check(user);
    if (!cacheWasUsed) 
        this.userCache.putUserInCache(user);
    

    Object principalToReturn = user;
    if (this.forcePrincipalAsString) 
        principalToReturn = user.getUsername();
    

    return this.createSuccessAuthentication(principalToReturn, authentication, user);

(5) retrieveUserAbstractUserDetailsAuthenticationProvider中有retrieveUser方法, 但是实现该方法的对象是DaoAuthenticationProvider, 该对象重写了retrieveUser方法, 在retrieveUser方法中, 可以看到调用了UserDetailsServiceloadUserByUsername()方法, 该方法用来根据用户名查询内存或者其他数据源中的用户. 默认是基于内存查找, 我们可以自定义为数据库查询. 查询后的结果封装成UserDetails 对象, 该对象包含用户名、加密密码、权限以及账户相关信息. 密码的加密处理是SpringSecurity帮我们处理

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException 
    this.prepareTimingAttackProtection();

    try 
    	// 调用该方法返回一个UserDetails 对象
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) 
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
         else 
            return loadedUser;
        
     catch (UsernameNotFoundException var4) 
        this.mitigateAgainstTimingAttack(authentication);
        throw var4;
     catch (InternalAuthenticationServiceException var5) 
        throw var5;
     catch (Exception var6) 
        throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
    

三. JWT

1. 什么是JWT?

JWT主要用于用户登陆鉴权, 在之前可能会使用session和token认证, 下面简述三者session和JWT的区别

Session

用户向服务器发送一个请求时, 服务器并不知道该请求是谁发的, 所以在用户发送登录请求时, 服务器会将用户提交的用户名和密码等信息保存在session会话中(一段内存空间)。同时服务器保存的用户信息会生成一个sessionid(相当于用户信息是一个value值, 而sessionid是value值的key)返回给客户端, 客户端将sessionid保存到cookie中, 等到下一次请求客户端会将cookie一同请求给服务器做认证

如果用户过多, 必然会耗费大量内存, 在cookie中存放sessionid会存在暴露用户信息的风险

Token

token是一串随机的字符串也叫令牌, 其原理和session类似, 当用户登录时, 提交的用户名和密码等信息请求给服务端, 服务端会根据用户名或者其他信息生成一个token而不是sessionid, 这和sessionid唯一区别就是, token不再存储用户信息, 客户端下一次请求会携带token, 此时服务器根据此次token进行认证。

token认证时也会到数据库中查询, 会造成数据库压力过大。

JWT

JWT将登录时所有信息都存在自己身上, 并且以json格式存储, JWT不依赖Redis或者数据库, JWT安全性不太好, 所以不能存储敏感信息

2. SpringSecurity集成JWT

(1) 认证配置

a) 配置SpringSecurity

首先配置一个SpringSecurity的配置类, 因为是基于JWT进行认证, 所以需要在配置中禁用session机制, 并不是禁用整个系统的session功能

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter 
    @Autowired
    private UserServiceImpl userDetailsService;
    @Autowired
    private LoginFilter loginFilter;
    @Autowired
    private AuthFilter authFilter;


    @Override
    protected void configure(HttpSecurity http) throws Exception 
        // 禁用session机制 
        http.csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.authorizeRequests()
                // 指定某些接口不需要通过验证即可访问。像登陆、注册接口肯定是不需要认证的
                .antMatchers("/sec/login").permitAll()
                .anyRequest().authenticated()

                // 自定义权限配置
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() 
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) 
                        o.setAccessDecisionManager(customUrlDecisionManager);
                        o.setSecurityMetadataSource(customFilter);
                        return o;
                    
                )
                .and()
                // 禁用缓存
                .headers()
                .cacheControl();

        http.addFilterBefore(jwtAuthencationTokenFilter(), UsernamePasswordAuthenticationFilter.class);
        // 添加自定义未授权和未登陆结果返回
        http.exceptionHandling()
                .accessDeniedHandler(restfulAccessDeniedHandler)
                .authenticationEntryPoint(restAuthoricationEntryPoint);
    

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception 
        // 指定UserDetailService和加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception 
        return super.authenticationManager();
    

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

b) 实现登录接口

先按照正常流程, 实现一个登录的接口然后在业务层中实现

@PostMapping("/login")
public Res login(@RequestBody User user, HttpServletRequest request) 
    return userService.login(user, request);

在业务层中, 首先对密码和用户名进行检验, 然后更新security登录用户对象, 在此之前我们先来认识几个在SpringSecurity中重要的变量

Authentication: 存储了认证信息, 代表登录用户
SecurityContext: 上下文对象, 用来获取Authentication(用户信息)
SecurityContextHolder: 上下文管理对象, 用来在程序任何地方获取SecurityContext
UserDetails: 存储了用户的基本信息, 以及用户权限、是否被禁用等

Authentication中的认证信息有
Principal: 用户信息
Credentials: 用户凭证, 一般是密码
Authorities: 用户权限

@Override
public Res login(User user, HttpServletRequest request) 
    String username = user.getUsername();
    String password = user.getPassword();

    // 登陆 检测
    UserDetails userDetails = userDetailsService.loadUserByUsername(username);
    if(null == userDetails || !passwordEncoder.matches(password, userDetails.getPassword())) 
        return Res.error("用户名或密码不正确!");
    
    // 更新security登录用户对象
    UsernamePasswordAuthenticationToken authenticationToken = new
            UsernamePasswordAuthenticationToken(userDetails,
            null, userDetails.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);

    // 创建一个token
    String token = jwtTokenUtil.generateToken(userDetails);
    Map<

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

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

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

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

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

@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令牌的有效期
@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没有过期的情况下才能刷新,过期了就不能刷新了。需要重新登录。
@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"开放访问权限,如何开放访问权限,我们之前的文章已经讲过了。

@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会以此判断你的接口访问权限。
@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)配置方法中,加入如下配置:

 //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代码单独抽取出来,形成“服务网关”,放在接口资源的前端。当然“服务网关”的功能不只是鉴权、还要有请求转发的功能。

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


配置类代码

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter

    private UserDetailsService userDetailsService;
    private PasswordEncoder passwordEncoder;
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    SecurityConfig(MyUserDetailService myUserDetailService,JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter)
    
        this社交登录,spring-security-oauth2 和 spring-security-jwt?

Spring Security----JWT详解

单点登录JWT与Spring Security OAuth

带有 spring-boot 和 spring-security 的 JWT

Spring Security OAuth2 v5:NoSuchBeanDefinitionException:'org.springframework.security.oauth2.jwt.Jwt

spring boot + spring security + jwt + React 不工作

(c)2006-2024 SYSTEM All Rights Reserved IT常识