一、导入依赖
在实现自定义接口权限过滤之前,首先要导入依赖,首先是 SpringBoot 父依赖。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.0</version> </parent>
接下来是 Spring Boot Web 依赖,用于提供最基本的接口支持。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
在实现登录时需要设计 Token,整合 Redis,所以需要加上以下依赖。
<dependency> <groupId>org.springframework.integration</groupId> <artifactId>spring-integration-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.21.3</version> </dependency>
最后就是 Spring Security 依赖,用于实现权限控制。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency>
Spring Boot 3.1 版本对应的 Spring Security 依赖为 6.1.0 版本,废弃了 WebSecurityConfigurerAdapter 类,配置内容和 Spring Security 5 有着明显不同,版本依赖如下图所示。
二、编写登录提示接口
要实现自定义接口权限过滤,首先要定义一个登录提示接口,用于被拦截时返回用户的数据,如下图所示。
请同学们新建一个控制器 SecurityController
,定义这个 /zwz/common/needLogin
接口,代码如下。
@RestController @RequestMapping("/zwz/common") @Api(tags = "公共接口") @Transactional public class SecurityController { @RequestMapping(value = "/needLogin", method = RequestMethod.GET) @ApiOperation(value = "未登录返回的数据") public Result<Object> needLogin(){ return ResultUtil.error(401, "登录失效"); } }
三、编写登录成功处理函数
很多同学会问,Spring Security项目中登录接口在哪里,如何实现登录功能,此时…
在 Spring Security 中,我们只需要对登录接口、登录成功/失败回调、过滤器等内容进行配置即可,开发者无需关注登录的具体实现。
请同学们新建 AuthenticationSuccessHandler
类,继承于 Spring Security 的 SavedRequestAwareAuthenticationSuccessHandler
类 ,用于回调用户登录成功的方法,。
@ApiOperation(value = "登录成功回调") @Slf4j @Component public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { }
3.1 判断是否保存登录
请同学们首先重写 AuthenticationSuccessHandler
类中的 onAuthenticationSuccess
方法,这个方法用于实现登录成功回调。
@Override @ApiOperation(value = "登录成功回调") @SystemLog(about = "登录系统", type = LogType.LOGIN) public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication ac) throws IOException, ServletException { }
在实现登录成功回调的开始,首先要判断用户是否勾选了自动登录,获取是否登录的代码如下。
String saveLogin = request.getParameter(ZwzLoginProperties.SAVE_LOGIN_PRE); Boolean saveLoginFlag = false; if(!ZwzNullUtils.isNull(saveLogin) && Objects.equals(saveLogin,"true")){ saveLoginFlag = true; }
这样就可以把用户是否保存登录的标识保存在 saveLoginFlag
变量中,以备后续保存 Token 使用。
3.2 保存用户的信息和菜单权限
用户登录成功后,需要加载用户的菜单,此时需要把用户的账号和菜单保存到缓存中。
用户第二次免登进入系统,即可快速从缓存中获取菜单数据,加快用户的菜单加载速度,降低数据库的读取压力。
首先定义一个 TokenUser
类,用于存储临时用户信息(账号、菜单权限),代码如下。
@ApiOperation(value = "临时用户类") @Data @AllArgsConstructor @NoArgsConstructor public class TokenUser implements Serializable{ private static final long serialVersionUID = 1L; @ApiModelProperty(value = "用户名") private String username; @ApiModelProperty(value = "拥有的菜单权限") private List<String> permissions; @ApiModelProperty(value = "是否自动登录") private Boolean saveLogin; }
接着继续在 onAuthenticationSuccess
方法中实现菜单拉取。
List<String> permissionsList = new ArrayList<>(); List<GrantedAuthority> authorities = (List<GrantedAuthority>) ((UserDetails)ac.getPrincipal()).getAuthorities(); for(GrantedAuthority g : authorities){ permissionsList.add(g.getAuthority()); } String username = ((UserDetails)ac.getPrincipal()).getUsername(); TokenUser user = new TokenUser(username, permissionsList, saveLoginFlag);
上述代码将指定用户的菜单权限拉取出来,保存到 TokenUser
临时用户类中,以备后续调用。
3.3 单点登录处理
单点登录(SSO)是一种身份认证的技术或协议,允许用户在多个应用系统中使用同一组凭据(例如用户名和密码),只需进行一次身份验证即可访问所有的应用系统,从而实现了不同应用系统之间的身份认证信息共享。这种技术可以提高用户的使用便利性,避免重复登录,减少密码管理负担,同时也能够增强系统的安全性,降低密码泄露和被攻击的风险。
对于一般的管理系统来说,都支持单点登录。
通俗点来说,单点登录就是单个账号只允许一个地点登录,电脑 A 登录时,如果登录到电脑 B,此时电脑 A 会被 “顶” 下线。
接着继续在 onAuthenticationSuccess
方法中实现单点登录。
public static final String HTTP_TOKEN_PRE = "ZWZ_TOKEN_PRE:"; public static final String USER_TOKEN_PRE = "ZWZ_USER_TOKEN:";
String oldToken = redisTemplate.opsForValue().get(ZwzLoginProperties.USER_TOKEN_PRE + username); if(StrUtil.isNotBlank(oldToken)){ redisTemplate.delete(ZwzLoginProperties.HTTP_TOKEN_PRE + oldToken); }
如果老的 Token 还存在,就把老 Token 删除,即可实现单点登录功能。
3.4 持久化登录信息
最后,将用户的数据持久化到 Redis 中,将 Token 返回给前端,存储到 Cookie 中,前端就可以使用 Token 免登进入、访问系统。
首先是 Token 的生成,可以采用 UUID 类辅助生成,代码如下。
String token = UUID.randomUUID().toString();
注入 Redis 工具类,代码如下。
import org.springframework.data.redis.core.StringRedisTemplate; @Autowired private StringRedisTemplate redisTemplate;
接着根据 3.1 步中的 是否保存
结果,进行持久化的处理,同学们可以自定义 Token 的前缀 USER_TOKEN_PRE
。
public static final String HTTP_TOKEN_PRE = "ZWZ_TOKEN_PRE:"; public static final String USER_TOKEN_PRE = "ZWZ_USER_TOKEN:";
if(saveLoginFlag){ redisTemplate.opsForValue().set(ZwzLoginProperties.USER_TOKEN_PRE + username, token, 30, TimeUnit.DAYS); redisTemplate.opsForValue().set(ZwzLoginProperties.HTTP_TOKEN_PRE + token, JSON.toJSONString(user), 30, TimeUnit.DAYS); }else{ redisTemplate.opsForValue().set(ZwzLoginProperties.USER_TOKEN_PRE + username, token, 60, TimeUnit.MINUTES); redisTemplate.opsForValue().set(ZwzLoginProperties.HTTP_TOKEN_PRE + token, JSON.toJSONString(user), 60, TimeUnit.MINUTES); } ResponseUtil.out(response, ResponseUtil.resultMap(true, 200, "登录成功", token));
当用户勾选了保存登录,系统保存 Token 30天,即用户可以在未来 30 天内,免登进入系统。
如果用户没有勾选保存登录,系统保存 Token 60 分钟,即用户可以在未来 1 小时内,免登进入系统。
完整代码如下。
@ApiOperation(value = "登录成功回调") @Slf4j @Component public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @Autowired private ZwzLoginProperties tokenProperties; @Autowired private StringRedisTemplate redisTemplate; private static final boolean RESPONSE_SUCCESS_FLAG = true; private static final int RESPONSE_SUCCESS_CODE = 200; private static final String TOKEN_REPLACE_STR_FRONT = "-"; private static final String TOKEN_REPLACE_STR_BACK = ""; @Override @ApiOperation(value = "登录成功回调") @SystemLog(about = "登录系统", type = LogType.LOGIN) public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication ac) throws IOException, ServletException { String saveLogin = request.getParameter(ZwzLoginProperties.SAVE_LOGIN_PRE); Boolean saveLoginFlag = false; if(!ZwzNullUtils.isNull(saveLogin) && Objects.equals(saveLogin,"true")){ saveLoginFlag = true; } List<String> permissionsList = new ArrayList<>(); List<GrantedAuthority> authorities = (List<GrantedAuthority>) ((UserDetails)ac.getPrincipal()).getAuthorities(); for(GrantedAuthority g : authorities){ permissionsList.add(g.getAuthority()); } String token = UUID.randomUUID().toString().replace(TOKEN_REPLACE_STR_FRONT, TOKEN_REPLACE_STR_BACK); String username = ((UserDetails)ac.getPrincipal()).getUsername(); TokenUser user = new TokenUser(username, permissionsList, saveLoginFlag); // 判断是否存储菜单权限 if(!tokenProperties.getSaveRoleFlag()){ user.setPermissions(null); } // 单点登录判断 if(tokenProperties.getSsoFlag()){ String oldToken = redisTemplate.opsForValue().get(ZwzLoginProperties.USER_TOKEN_PRE + username); if(StrUtil.isNotBlank(oldToken)){ redisTemplate.delete(ZwzLoginProperties.HTTP_TOKEN_PRE + oldToken); } } if(saveLoginFlag){ redisTemplate.opsForValue().set(ZwzLoginProperties.USER_TOKEN_PRE + username, token, tokenProperties.getUserSaveLoginTokenDays(), TimeUnit.DAYS); redisTemplate.opsForValue().set(ZwzLoginProperties.HTTP_TOKEN_PRE + token, JSON.toJSONString(user), tokenProperties.getUserSaveLoginTokenDays(), TimeUnit.DAYS); }else{ redisTemplate.opsForValue().set(ZwzLoginProperties.USER_TOKEN_PRE + username, token, tokenProperties.getUserTokenInvalidDays(), TimeUnit.MINUTES); redisTemplate.opsForValue().set(ZwzLoginProperties.HTTP_TOKEN_PRE + token, JSON.toJSONString(user), tokenProperties.getUserTokenInvalidDays(), TimeUnit.MINUTES); } ResponseUtil.out(response, ResponseUtil.resultMap(RESPONSE_SUCCESS_FLAG,RESPONSE_SUCCESS_CODE,"登录成功", token)); } }
四、编写登录失败处理函数
请同学们新建 AuthenticationFailHandler
类,继承于 Spring Security 的 SimpleUrlAuthenticationFailureHandler
类 ,用于回调用户登录失败 的方法,。
@ApiOperation(value = "登录失败回调") @Slf4j @Component public class AuthenticationFailHandler extends SimpleUrlAuthenticationFailureHandler { }
4.1 判断是否密码错误
请同学们首先重写 AuthenticationFailHandler
类中的 onAuthenticationFailure
方法,这个方法用于实现登录失败回调。
@Override @ApiOperation(value = "登录失败回调") public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) { }
在实现登录失败回调的开始,首先要判断用户是否输入了错误的密码。
其中错误的密码包括:
- 用户密码输入错误。(UsernameNotFoundException 异常)
- 用户输入了正确的密码,但没有加密。(BadCredentialsException 异常)
if (exception instanceof BadCredentialsException || exception instanceof UsernameNotFoundException) { recordLoginTime(request.getParameter("username:")); String failTimesStr = stringRedisTemplate.opsForValue().get("LOGIN_FAIL_TIMES_PRE:" + request.getParameter("username:")); // 已错误的次数 int userFailTimes = 0; if(!ZwzNullUtils.isNull(failTimesStr)){ userFailTimes = Integer.parseInt(failTimesStr); } int restLoginTime = 10 - userFailTimes; if(restLoginTime < 5 && restLoginTime > 0){ ResponseUtil.out(response, ResponseUtil.resultMap(false,500,"账号密码不正确,还能尝试登录" + restLoginTime + "次")); } else if(restLoginTime < 1) { ResponseUtil.out(response, ResponseUtil.resultMap(false,500,"重试超限,请您" + 10 + "分后再登录")); } else { ResponseUtil.out(response, ResponseUtil.resultMap(false,500,"账号密码不正确")); } }
默认设定 10 次登录试错次数,如果超限会被临时禁止登录。
其中 recordLoginTime
方法用于查询登录失败的次数,代码如下。
@ApiOperation(value = "查询登录失败的次数") public boolean recordLoginTime(String username) { String loginFailTimeStr = stringRedisTemplate.opsForValue().get(LOGIN_FAIL_TIMES_PRE + username); int loginFailTime = 0; // 已错误次数 if(!ZwzNullUtils.isNull(loginFailTimeStr)){ loginFailTime = Integer.parseInt(loginFailTimeStr) + 1; } stringRedisTemplate.opsForValue().set("LOGIN_FAIL_TIMES_PRE:" + username, loginFailTime + "", 10, TimeUnit.MINUTES); if(loginFailTime >= 10){ stringRedisTemplate.opsForValue().set("userLoginDisableFlag:"+username, "fail", 10, TimeUnit.MINUTES); return false; } return true; }
4.2 判断是否系统鉴权失败
接着判断是否是自定义异常 ZwzAuthException
,如果属于自定义异常,则抛出自定义异常的信息,代码如下。
if (exception instanceof ZwzAuthException){ ResponseUtil.out(response, ResponseUtil.resultMap(false,500,((ZwzAuthException) exception).getMsg())); }
其中自定义异常定义如下。
@ApiOperation(value = "自定义异常") public class ZwzAuthException extends InternalAuthenticationServiceException { private static final long serialVersionUID = 1L; private static final String DEFAULT_MSG = "系统鉴权失败"; @ApiModelProperty(value = "异常消息内容") private String msg; public ZwzAuthException(String msg){ super(msg); this.msg = msg; } public ZwzAuthException(){ super(DEFAULT_MSG); this.msg = DEFAULT_MSG; } public ZwzAuthException(String msg, Throwable t) { super(msg, t); this.msg = msg; } public String getMsg() { return msg; } public void setMsg(String msg) { this.msg = msg; } }