Spring Security实战代码详解
一、配置文件application.yml
spring:application:name:security-08-login-api# 统一设置时区 jsckson进行json转换时使用指定的时区和格式进行转换jackson:time-zone: GMT+8date-format: yyyy-MM-dd HH:mm:ssdatasource:username: rootpassword: 1234url: jdbc:mysql://localhost:3306/ykdldriver-class-name: com.mysql.cj.jdbc.Driver# 连接Redisdata:redis:host: 192.168.227.129port: 6379password: 111database: 0# ✅ 添加 MyBatis 配置
mybatis:mapper-locations: classpath:mapper/*.xml二、登录认证流程
步骤 1️⃣:访问首页 / → 跳转登录页(未登录时)
触发点:用户访问
http://localhost:8080/代码位置:
UserController.index()
// 若未登录就跳转到登录页,若登录则返回字符串
// http://localhost:8080/ --> 没登录 --> http://localhost:8080/toLogin --> login.html页面 --> 查询数据库登录
// http://localhost:8080/ --> 登录了 --> 返回"Welcome to Spring Security."字符串
@RequestMapping(value = "/")
// @ResponseBody注解,表示方法返回字符串或者json
@ResponseBody
public String index() {return "Welcome to Spring Security.";
}@RequestMapping(value = "/toLogin")
public String toLogin() {return "login";
} 页面跳转:
/toLogin→ 渲染login.html
login.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><script src="js/axios.js"></script><!--<script src="https://unpkg.com/vue@3.5.3/dist/vue.global.js"></script>--><script src="js/vue.global.js"></script><title>登录</title>
</head><body><form>账号:<input type="text" id="username" name="username"> <br/>密码:<input type="password" id="password" name="password"> <br/>验证码:<input type="text" name="captcha"><img src="/common/captcha"/> <br/><input type="button" value="登 录" onclick="login()"></form>
</body>
<script type="text/javascript">function login() {let username = document.getElementById('username').value;let password = document.getElementById('password').value;// 因为获取数据是通过request.getParameter(this.usernameParameter)获取到的// 如果通过json格式那么获取不到let formData = new FormData(); // js表单数据对象formData.append("username", username); //把登录账号放入到了js表单数据对象中formData.append("password", password); //把登录密码放入到了js表单数据对象中axios.post('http://localhost:8080/user/login', formData).then( (response) => {console.log(response);// 进行登录结果的判断,和 页面的跳转if (response.data.code === 200) { // 表示登录成功// response中有6个字段, 取其中的data, 再取其中的infoconsole.log("token", response.data.info)// 把token存储在浏览器中window.sessionStorage.setItem("loginToken", response.data.info) // 只在当前页面有效// window.localStorage.setItem("loginToken", response.data.info) // 在当前浏览器有效window.location.href = "welcome.html"} else {alert(response.data.msg);}}).catch( (error) => {console.log(error);});}
</script>步骤 2️⃣:显示验证码图片
- 前端请求:
<img src="/common/captcha"> - 代码位置:
CaptchaController.generateCaptcha()
public class MyCodeGenerator implements CodeGenerator {@Overridepublic String generate() {int code = 1000 + new Random().nextInt(9000); // 0 - 8999 => [1000 , 9999]return String.valueOf(code);}@Overridepublic boolean verify(String code, String userInputCode) {return false;}
}数据存储:验证码字符串存入 HTTP Session(key="captcha")
步骤 3️⃣:提交登录表单(含验证码)
- 前端提交地址:
POST /user/login - 携带参数:
username(登录账号)password(密码)captcha(用户输入的验证码)
首先进行验证码验证:
@Component
// 在spring框架中,相比实现Filter,直接继承OncePerRequestFilter更方便
public class CaptchaFilter extends OncePerRequestFilter {@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {String code = request.getParameter("captcha");String sessionCode = (String) request.getSession().getAttribute("captcha");String requestUri = request.getRequestURI(); // /user/login// 如果是登录请求,就验证验证码,否则不需要验证验证码if (requestUri.equals("/user/login")) {if (!StringUtils.hasText(code)) { //前面加了一个“!” 表示非,取反,那就是如果code是空的// 验证没通过response.sendRedirect("/");} else if (!code.equalsIgnoreCase(sessionCode)) { //如果前端传过来的验证码和后端session中存放的验证码不相等// 验证没通过response.sendRedirect("/");} else {// 验证码相等,可以放行,继续执行下一个filterfilterChain.doFilter(request, response);}} else {// 不是登录请求,不需要验证验证码,直接放行filterChain.doFilter(request, response);}}
}跳转逻辑:验证码错误 → 重定向到
/(首页)
步骤 4️⃣:Spring Security 执行身份认证
- 入口:
/user/login被 Spring Security 拦截 - 认证逻辑:调用
UserServiceImpl.loadUserByUsername(username) - 密码比对(使用
BCryptPasswordEncoder)
@Service
public class UserServiceImpl implements UserService {@Resourceprivate TUserMapper tUserMapper;@Resourceprivate TPermissionMapper tPermissionMapper;/*** 该方法在spring security框架登录的时候被调用** @param username* @return* @throws UsernameNotFoundException*/@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {//查询数据库,查询页面上传过来的这个用户名是否在数据库中存在,也就是根据该username查询用户对象TUser tUser = tUserMapper.selectByLoginAct(username); //catif (tUser == null) {throw new UsernameNotFoundException("登录账号不存在");}//查询该用户的权限code列表(一个用户可能有多个权限code)List<TPermission> tPermissionList = tPermissionMapper.selectByUserId(tUser.getId());//把查询出来的角色放入用户对象中tUser.setTPermissionList(tPermissionList);//返回该用户对象return tUser;}
}登录成功 → 生成 JWT Token
@Bean // 安全过滤器链Bean
// httpSecurity是方法参数注入Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, CorsConfigurationSource configurationSource) throws Exception {return httpSecurity// 配置自己的登录页 表单登录.formLogin(new Customizer<FormLoginConfigurer<HttpSecurity>>() {@Overridepublic void customize(FormLoginConfigurer<HttpSecurity> formLogin) {// 框架默认接收登录提交请求的地址是 /login,但是把它给弄丢了,需要捡回来formLogin.loginProcessingUrl("/user/login") //登录的账号密码往哪个地址提交.successHandler(myAuthenticationSuccessHandler) //登录成功后执行该handler.failureHandler(myAuthticationFailureHandler); //登录失败后执行该handler// 在访问 /user/login 地址之前,对于后端应用来说,没有上一个地址(原地),那默认是跳转到项目的根路径斜杆/}}).build();
}
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {// 直接注入该RedisTemplate就可以操作redis了@Resourceprivate RedisTemplate<String, Object> redisTemplate;@Overridepublic void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {// 前端控制跳转页面,后端在登录成功后,返回JSON数据给前端response.setContentType("application/json");response.setCharacterEncoding("utf-8");// 生成jwt (token)TUser tUser = (TUser) authentication.getPrincipal();String userJSON = JSONUtil.toJsonStr(tUser);// Map.of("user", userJSON)指的是负载数据String token = JWTUtil.createToken(Map.of("user", userJSON), Constant.SECRET.getBytes());// token写入Redis (五种值的结构类型:string,hash,list,set,zset)redisTemplate.opsForValue(); // 操作stringredisTemplate.opsForHash(); // 操作hashredisTemplate.opsForList(); // 操作listredisTemplate.opsForSet(); // 操作setredisTemplate.opsForZSet(); // 操作zset value按照分数从小到大排序// token适合使用string或者hash结构进行存储// key是用户的id,value就是用户的token String结构存储// hash存储:key, 用户id, tokenredisTemplate.opsForHash().put(Constant.REDIS_TOKEN_KEY, String.valueOf(tUser.getId()), token);// 因为如果直接使用tUser.getId(),那么在Redis中采用string进行序列化会报错// 测试一下,怎么把redis的值取出来// String redisToken = (String)redisTemplate.opsForHash().get(Constant.REDIS_TOKEN_KEY, tUser.getId());//采用构建器模式,链式编程创建一个R对象// R result = R.builder().code(200).msg("登录成功").info(authentication).build();R result = R.builder().code(200).msg("登录成功").info(token).build();//hutool工具包,把R对象转成json字符串String json = JSONUtil.toJsonStr(result);//把json写出去,写出到浏览器客户端response.getWriter().write(json);}
}数据返回:JSON 格式
{code:200, msg:"登录成功", info:"<JWT_TOKEN>"}
Token 存储:Redis Hash 结构,key=security:user:login,field=userId,value=token
登录失败 → 返回错误信息
@Component
public class MyAuthticationFailureHandler implements AuthenticationFailureHandler {@Overridepublic void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {response.setContentType("application/json");response.setCharacterEncoding("utf-8");// 采用构建器模式,链式编程创建一个R对象R result = R.builder().code(500).msg("登录失败:" + exception.getMessage()).build();// hutool工具包,把R对象转成json字符串String json = JSONUtil.toJsonStr(result);// 把json写出去,写出到浏览器客户端response.getWriter().write(json);}
}数据返回:JSON 错误信息(如“用户名不存在”、“密码错误”)
三、权限检验与接口访问流程
步骤 5️⃣:后续请求携带 Token
前端在 Header 中添加:自定义 Header:token
步骤 6️⃣:TokenFilter 验证 Token 合法性
@Component
public class TokenFilter extends OncePerRequestFilter {@Resourceprivate RedisTemplate<String, Object> redisTemplate;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {response.setContentType("application/json");response.setCharacterEncoding("utf-8");// 登录接口,不需要验证token(因为登录时,还没有生成token)String requestUri = request.getRequestURI(); // /user/loginif (requestUri.equals("/user/login")) { // 如果是登录请求,我们不需要验证token// 直接放行,不需要验证tokenfilterChain.doFilter(request, response);} else {String token = request.getHeader("token"); //从请求头中获取token的值if (!StringUtils.hasText(token)) { // 前面有个 “非”R result = R.builder().code(901).msg("请求Token为空").build();// result转为json格式response.getWriter().write(JSONUtil.toJsonStr(result));} else {boolean verify = false; // 验证的初始值是false,false表示验证未通过try {// 验证通过了,则verify = trueverify = JWTUtil.verify(token, Constant.SECRET.getBytes());} catch (Exception e) {e.printStackTrace();}if (!verify) { //前面有个 “非”R result = R.builder().code(902).msg("请求Token不合法").build();response.getWriter().write(JSONUtil.toJsonStr(result));} else {JSONObject payloads = JWTUtil.parseToken(token).getPayloads();String userJSON = payloads.get("user", String.class);// 通过json反向获取User对象TUser tUser = JSONUtil.toBean(userJSON, TUser.class);Integer userId = tUser.getId();// 拿redis的tokenString redisToken = (String) redisTemplate.opsForHash().get(Constant.REDIS_TOKEN_KEY, String.valueOf(userId));if (!token.equals(redisToken)) { //前面有个 “非”R result = R.builder().code(903).msg("请求Token错误").build();response.getWriter().write(JSONUtil.toJsonStr(result));} else {// token验证通过了,// 要在spring security的上下文中放置一个认证对象,这样的话,spring security在执行后续的Filter的时候,才知道这个人是登录了的// 认证对象,密码,权限UsernamePasswordAuthenticationToken authenticationToken= new UsernamePasswordAuthenticationToken(tUser, null, tUser.getAuthorities());SecurityContextHolder.getContext().setAuthentication(authenticationToken); // 认证信息// 放行,让代码继续去执行下一个FilterfilterChain.doFilter(request, response);}}}}}
}关键点:只有 Token 合法且与 Redis 一致,才视为已登录;
步骤 7️⃣:访问受保护接口(如 /api/clue/list)
//权限标识符的命名: 模块名:功能名(clue:list) 或者 项目名:模块名:功能名(dlyk:clue:list)
@PreAuthorize(value = "hasAuthority('clue:list')")
@RequestMapping(value = "/api/clue/list")
public String clueList() {return "clueList";
}TUser.getAuthorities()返回List<SimpleGrantedAuthority>,内容为tPermission.code- Spring Security 检查当前用户是否包含
'clue:list'权限;
无权限→ 返回 401
public class MyAccessDeniedHandler implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {// 返回json,告诉前端: 权限不足response.setContentType("application/json");response.setCharacterEncoding("utf-8");// 采用构建器模式,链式编程创建一个R对象R result = R.builder().code(401).msg("权限不足").build();// hutool工具包,把R对象转成json字符串String json = JSONUtil.toJsonStr(result);// 把json写出去,写出到浏览器客户端response.getWriter().write(json);}
}四、退出登录流程
- 前端请求:
POST /user/logout
@Component
public class AppLogoutSuccessHandler implements LogoutSuccessHandler {@Resourceprivate RedisTemplate<String, Object> redisTemplate;@Overridepublic void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {// 因为前后端分离,所以返回都使用json格式返回response.setContentType("application/json");response.setCharacterEncoding("utf-8");TUser tUser = (TUser) authentication.getPrincipal();// 退出成功,需要把redis中登录的token删除一下redisTemplate.opsForHash().delete(Constant.REDIS_TOKEN_KEY, String.valueOf(tUser.getId()));//采用构建器模式,链式编程创建一个R对象R result = R.builder().code(200).msg("退出成功").info(authentication).build();// hutool工具包,把R对象转成json字符串String json = JSONUtil.toJsonStr(result);//把json写出去,写出到浏览器客户端response.getWriter().write(json);}
}效果:Redis 中 Token 被删除 → 下次请求即使携带旧 Token 也会失败(903 错误)
五、关闭时清理Token
@Component
public class ApplicationShutdownListener implements ApplicationListener<ContextClosedEvent> {@Resourceprivate RedisTemplate<String, Object> redisTemplate;/*** spring context关闭时,会触发执行该方法** @param event*/@Overridepublic void onApplicationEvent(ContextClosedEvent event) {System.out.println("spring context 关闭了......");// 让登录用户的token失效,怎么失效?拿就是把redis中的token删除redisTemplate.delete(Constant.REDIS_TOKEN_KEY);}
}服务关闭/重启,删除redis的所有jwt
Session 是什么?
- Session 是服务器为每个用户创建的一个会话对象,用于在多次 HTTP 请求之间保存用户的状态信息(比如登录状态、验证码、购物车等)。
- 因为 HTTP 协议本身是无状态的(每次请求独立),所以需要 Session 来“记住”用户。
Session 存在哪里?
- 默认情况下:存在服务器的内存中(例如 Tomcat 的 JVM 堆内存)。
- 也可以配置:存到 Redis、数据库等(用于集群环境共享 Session)。
- 绝不会存在前端(浏览器)!前端无法直接访问 Session 内容。
前端怎么“关联”到自己的 Session?
- 服务器在创建 Session 时,会生成一个唯一的 Session ID(如
JSESSIONID=abc123xyz)。 - 服务器通过 Set-Cookie 响应头把这个 ID 发给浏览器,
- 浏览器收到后,会在后续请求中自动带上这个 Cookie,
- 服务器通过这个 ID 找到对应的 Session 对象。
request.getSession().setAttribute("captcha", captcha.getCode());
