Spring - ruoyi系统鉴权流程
rouyi系统鉴权流程
后端主要逻辑
在验证用户名、密码进行登录之前,在 gateway 模块下的 ValidateCodeFilter 中,拦截需要校验验证码的请求:
@Override public GatewayFilter apply(Object config) { return (exchange, chain) -> { ServerHttpRequest request = exchange.getRequest(); // 非登录/注册请求或验证码关闭,不处理 if (!StringUtils.containsAnyIgnoreCase(request.getURI().getPath(), VALIDATE_URL) || !captchaProperties.getEnabled()) { return chain.filter(exchange); } try { String rspStr = resolveBodyFromRequest(request); JSONObject obj = JSONObject.parseObject(rspStr); validateCodeService.checkCapcha(obj.getString(CODE), obj.getString(UUID)); } catch (Exception e) { return ServletUtils.webFluxResponseWriter(exchange.getResponse(), e.getMessage()); } return chain.filter(exchange); }; }
将需要校验验证码的请求,发送到 gateway 模块下的 ValidateCodeService 中验证:
/** * 校验验证码 */ @Override public void checkCapcha(String code, String uuid) throws CaptchaException { if (StringUtils.isEmpty(code)) { throw new CaptchaException("验证码不能为空"); } if (StringUtils.isEmpty(uuid)) { throw new CaptchaException("验证码已失效"); } String verifyKey = Constants.CAPTCHA_CODE_KEY + uuid; String captcha = redisService.getCacheObject(verifyKey); redisService.deleteObject(verifyKey); if (!code.equalsIgnoreCase(captcha)) { throw new CaptchaException("验证码错误"); } }
验证码校验通过后,进入用户登录流程,请求走到 auth 模块下的 TokenController:
@PostMapping("login") public R<?> login(@RequestBody LoginBody form) { // 用户登录 LoginUser userInfo = sysLoginService.login(form.getUsername(), form.getPassword()); // 获取登录token return R.ok(tokenService.createToken(userInfo)); }
login 动作分两步,第一步是校验用户名、密码并获取当前用户信息,第二步是基于获取到的用户信息生成 token 缓存。
在 auth 模块下的 SysLoginService 中,进行密码校验和获取详细信息操作:
/** * 登录 */ public LoginUser login(String username, String password) { ...
// 查询用户信息 R<LoginUser> userResult = remoteUserService.getUserInfo(username, SecurityConstants.INNER); if (R.FAIL == userResult.getCode()) { throw new ServiceException(userResult.getMsg()); } if (StringUtils.isNull(userResult) || StringUtils.isNull(userResult.getData())) { recordLogininfor(username, Constants.LOGIN_FAIL, "登录用户不存在"); throw new ServiceException("登录用户:" + username + " 不存在"); } LoginUser userInfo = userResult.getData(); SysUser user = userResult.getData().getSysUser();
... recordLogininfor(username, Constants.LOGIN_SUCCESS, "登录成功"); return userInfo; }
然后在 common 模块下的 security 中的 TokenService中,将获取到的 useInfo 和 token 信息存储到 redis:
/** * 创建令牌 */ public Map<String, Object> createToken(LoginUser loginUser) { String token = IdUtils.fastUUID(); Long userId = loginUser.getSysUser().getUserId(); String userName = loginUser.getSysUser().getUserName(); loginUser.setToken(token); loginUser.setUserid(userId); loginUser.setUsername(userName); loginUser.setIpaddr(IpUtils.getIpAddr(ServletUtils.getRequest())); // 保存到redis refreshToken(loginUser); // Jwt存储信息 Map<String, Object> claimsMap = new HashMap<String, Object>(); claimsMap.put(SecurityConstants.USER_KEY, token); claimsMap.put(SecurityConstants.DETAILS_USER_ID, userId); claimsMap.put(SecurityConstants.DETAILS_USERNAME, userName); // 接口返回信息 Map<String, Object> rspMap = new HashMap<String, Object>(); rspMap.put("access_token", JwtUtils.createToken(claimsMap)); rspMap.put("expires_in", expireTime); return rspMap; }
之后在请求资源的时候,gateway 模块中的 AuthFilter 会根据请求头中的 token,从 redis 中查询出 userId 和 userName,重新添加到请求头中,然后执行接下来的调用链:
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); ServerHttpRequest.Builder mutate = request.mutate(); String url = request.getURI().getPath(); // 跳过不需要验证的路径 if (StringUtils.matches(url, ignoreWhite.getWhites())) { return chain.filter(exchange); } String token = getToken(request); if (StringUtils.isEmpty(token)) { return unauthorizedResponse(exchange, "令牌不能为空"); } Claims claims = JwtUtils.parseToken(token); if (claims == null) { return unauthorizedResponse(exchange, "令牌已过期或验证不正确!"); } String userkey = JwtUtils.getUserKey(claims); boolean islogin = redisService.hasKey(getTokenKey(userkey)); if (!islogin) { return unauthorizedResponse(exchange, "登录状态已过期"); } String userid = JwtUtils.getUserId(claims); String username = JwtUtils.getUserName(claims); if (StringUtils.isEmpty(userid) || StringUtils.isEmpty(username)) { return unauthorizedResponse(exchange, "令牌验证失败"); } // 设置用户信息到请求 addHeader(mutate, SecurityConstants.USER_KEY, userkey); addHeader(mutate, SecurityConstants.DETAILS_USER_ID, userid); addHeader(mutate, SecurityConstants.DETAILS_USERNAME, username); // 内部请求来源参数清除 removeHeader(mutate, SecurityConstants.FROM_SOURCE); return chain.filter(exchange.mutate().request(mutate.build()).build()); }
当请求到达对应的Controller后,通过 @RequiresPermissions 判断用户是否有权限访问该资源,注解中的字符串就是自定义的权限字符串:
/** * 获取用户列表 */ @RequiresPermissions("system:user:list") @GetMapping("/list") public TableDataInfo list(SysUser user) { startPage(); List<SysUser> list = userService.selectUserList(user); return getDataTable(list); }
自定义注解 RequiresPermissions 实现在 common 模块下的 security 模块中的 PreAuthorizeAspect 切面定义中。原理就是用当前 permissions 字符串和用户拥有的 permissions 相比较,有重合即为满足权限:
/** * 对一个Method对象进行注解检查 */ public void checkMethodAnnotation(Method method) { // 校验 @RequiresLogin 注解 RequiresLogin requiresLogin = method.getAnnotation(RequiresLogin.class); if (requiresLogin != null) { AuthUtil.checkLogin(); } // 校验 @RequiresRoles 注解 RequiresRoles requiresRoles = method.getAnnotation(RequiresRoles.class); if (requiresRoles != null) { AuthUtil.checkRole(requiresRoles); } // 校验 @RequiresPermissions 注解 RequiresPermissions requiresPermissions = method.getAnnotation(RequiresPermissions.class); if (requiresPermissions != null) { AuthUtil.checkPermi(requiresPermissions); } }
三种鉴权方式
RequireLogin:需要登录才能调用
RequireRoles:需要拥有某些角色才能调用
RequirePermissions:需要拥有某些权限才能调用
关于前端
大家可能注意到前端在设置 Authorization 的 token 值的时候,需要在 token 前面加一个"Bearer "。Bearer 的中文翻译是“持票人”,在这里指的是当前 token 标准,在 RFC 6750 中,它的定义是这样的:
OAuth enables clients to access protected resources by obtaining an access token,
which is defined in "The OAuth 2.0 Authorization Framework " [RFC6749] " as a string representing an access authorization issued to the client",
rather than using the resource owner's credentials directly.
加上这个单词主要是为了标识当前 token 的标准。
OAuth 2.0 官网给出的定义在这:
https://oauth.net/2/bearer-tokens/