Skip to content

Redis 实现分布式系统下的 jwt token 认证方案及 spring security 授权

背景

随着业务的发展,许多系统都需要做分布式、集群来满足日益增长的业务数据。但是在这种系统下,用户认证用传统的方案就不行了。

所以我们可以使用如下图所示的方案,来构建无状态应用,来解决分布式系统单点登录,集群系统 session 共享的问题。 这个可以使我们的系统架构更方便的横向扩展,来支持更高的并发量。 image

认证服务 quickboot-auth-server

我们先搭建一个基于 RBAC1 规范的认证服务。quickboot-auth-server 打包后直接 java -jar 启动即可。

登录 url(POST 请求)

http://localhost:8080/login?username={username}&password={password}

响应:

json
{
  "code": 200,
  "data": {
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2p3dF9rZXkiOiJ2anpsYlAxMkszR3RrSndHdmVNTlpnPT0ifQ.djOwsV4uXXaZyx1XvGopAwYvPxuTX6-_GKlAuH3bJLloYKxdn25l6k4n_ucKg0LFTABM3EdSZNUoSEQlJ7aR1w"
  },
  "msgList": [],
  "time": "2022-01-17T14:04:07.4711254"
}

第三方验证接口 url(GET 请求)

http://localhost:8080/token/verify?token={token}

响应: true/false

登出 url(POST 请求)

http://localhost:8080/logout

需要携带 token 登出。token 默认放在 HttpServletRequest header 的 Authorization 中。

SQL: SQL 脚本 quickboot-auth-server 工程使用 flyway 自动做数据库迁移,可以不用手动执行 sql 脚本。只需配置 spring.flyway.enabled=true 即可。

quickboot-auth-server 全部源代码 码云:quickboot-auth-server

token 默认放在 HttpServletRequest header 的 Authorization 中。

RBAC 博客参考:基于角色的权限控制 RBAC

主要代码

token 签发

java
@Slf4j
@Component
public class QuickBootAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Autowired
    private LoginLogTask loginLogTask;

    @Autowired
    private AuthProperties authProperties;

    @Autowired
    private RedisCache redisCache;

    @Autowired
    private TokenService tokenService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        // UserDetails 中的 username 属性实际上在 UserDetailsServiceImpl 中存放的是 user 的 id
        String username = userDetails.getUsername();

        // 如果用户已经登录过了,那么删除之前的 token,重新生成一个新的存入 redis。避免在 Redis 中存在同一个用户有多个 token 的登录记录
        LoginUser loginUser = redisCache.getCacheObject(SecurityConst.REDIS_KEY_LOGIN_USERNAME_TOKEN + username);
        if(loginUser != null) {
            log.warn("User {} repeat login.", username);
            // 移除之前的 token
            redisCache.deleteObject(SecurityConst.REDIS_KEY_LOGIN_USERNAME_TOKEN + username);
        }

        String token = TokenUtils.createToken(authProperties.getToken().getSecret(), username);

        // 收集权限信息
        List<String> roleList = userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList());
        loginUser = new LoginUser().setUsername(username).setRoleList(roleList);

        redisCache.setCacheObject(SecurityConst.REDIS_KEY_LOGIN_USERNAME_TOKEN + username, loginUser, authProperties.getToken().getExpire(), TimeUnit.SECONDS);

        final R<?> r = R.ok(Collections.singletonMap("token", token));
        ServletUtils.render(response, r);

        // 异步记录登录成功的日志
        loginLogTask.addSuccessLoginLog(request, username);
    }
}

token 验证

java
@Component
public class TokenVerifyFilter extends OncePerRequestFilter {

    public static final String LOGIN_URL = "/login";

    public static final String VERIFY_URL = "/token/verify";

    @Autowired
    private TokenService tokenService;

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        // 跳过登录 url 和 token 验证 url
        return LOGIN_URL.equals(request.getRequestURI()) || VERIFY_URL.equals(request.getRequestURI());
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        // 获取请求携带的令牌
        String token = tokenService.getToken(request);
        if(StrUtil.isBlank(token)) {
            // 请求未携带 token
            R<?> r = R.fail(HttpStatus.BAD_REQUEST.value(), "No token was found!");
            ServletUtils.render(response, r);
            return;
        }
        final LoginUser loginUser = tokenService.getLoginUser(token);
        if(loginUser == null) {
            // token 已过期
            R<?> r = R.fail(HttpStatus.UNAUTHORIZED.value(), "Token expired!");
            ServletUtils.render(response, r);
            return;
        }

        // 走到这里说明 redis 中有 user,说明已经登录过了。刷新一下 token 过期时间,达到自动续约的目的
        tokenService.expireRefresh(loginUser.getUsername());

        if(SecurityContextHolder.getContext().getAuthentication() == null) {
            final UserDetails userDetails = loginUser.createUserDetails();
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }

        // 继续执行下一个过滤器
        chain.doFilter(request, response);
    }
}

第三方 token 验证接口

java
@Slf4j
@Validated
@RestController
@RequestMapping("/token")
public class TokenController {

    @Autowired
    private TokenService tokenService;

    @GetMapping("/verify")
    public boolean verify(@RequestParam("token") @NotBlank String token) {
        final LoginUser loginUser = tokenService.getLoginUser(token);
        if(loginUser != null) {
            // 自动续约 token
            tokenService.expireRefresh(loginUser.getUsername());
            return true;
        }
        return false;
    }

}

第三方客户端集成 quickboot-auth-client

quickboot-auth-client 全部源代码 码云:quickboot-auth-client

使用参考:码云:sample-quickboot-auth-client

xml
<dependency>
    <groupId>com.github.mengweijin</groupId>
    <artifactId>quickboot-auth-client</artifactId>
</dependency>

演示图

获取 token

image

第三方客户端携带 token 访问接口

image

token 过期

image