Redis 实现分布式系统下的 jwt token 认证方案及 spring security 授权
背景
随着业务的发展,许多系统都需要做分布式、集群来满足日益增长的业务数据。但是在这种系统下,用户认证用传统的方案就不行了。
所以我们可以使用如下图所示的方案,来构建无状态应用,来解决分布式系统单点登录,集群系统 session 共享的问题。 这个可以使我们的系统架构更方便的横向扩展,来支持更高的并发量。
认证服务 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 请求)
需要携带 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>