用户登录、Token 认证、权限控制、会话管理、拦截器配置整合
大家好,搞 Java 开发第八个年头了。这些年从单体项目摸到微服务,后端安全这块始终绕不开 “登录 + 认证 + 权限” 这三件套 —— 新手常犯的错是把这些模块拆得太散,要么 Token 校验和权限逻辑混在 Controller 里,要么拦截器配置漏了排除路径,上线后各种 401、403 报错头大。
今天就带大家搞个可落地的整合方案:用 JWT 做 Token 认证、RBAC 模型控权限、自定义拦截器做校验、会话用 Token 替代传统 Session,从表结构到配置代码全给齐,复制粘贴改改配置就能用。
一、先明确技术选型:实用优先,不堆花活
八年经验告诉我,后端选型不是越新越好,稳定、社区活跃才是王道。这套方案用的都是企业级项目里跑过无数遍的组件:
- 基础框架:Spring Boot 2.7.12(避开 3.x 的 JDK17 适配坑,大部分老项目还在用 2.x)
- Token 生成:JJWT(Java JWT,轻量且 API 友好,比自己手写签名靠谱)
- ORM:MyBatis-Plus 3.5.3.1(省掉 CRUD 代码,条件查询也方便)
- 密码加密:BCrypt(不可逆加密,比 MD5 + 盐安全,Spring Security 自带)
- 工具类:commons-lang3(判空、字符串处理)、fastjson2(JSON 序列化,比 Jackson 快一点)
二、Maven 坐标:直接复制,避免版本冲突
这是核心依赖,不用全量引 Spring Boot Starter,按需加就行,版本我都配过兼容:
<dependencies>
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.7.12</version>
</dependency>
<!-- JWT 依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!-- 数据库 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<!-- Lombok:省掉getter/setter -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.26</version>
<optional>true</optional>
</dependency>
</dependencies>
三、数据库表设计:RBAC 模型,最经典的权限方案
权限控制绕不开 RBAC(用户 - 角色 - 权限),这张表结构我在 3 个项目里用过,灵活度够 —— 既支持按角色控权(比如 “ADMIN” 角色能访问所有接口),也支持按单个权限控权(比如 “USER” 角色只能访问/api/user/info)。
1. 用户表(sys_user)
存储用户登录信息,密码一定要加密!
| 字段名 | 类型 | 长度 | 主键 | 非空 | 注释 |
|---|---|---|---|---|---|
| id | bigint | - | 是 | 是 | 用户 ID(自增) |
| username | varchar | 50 | 否 | 是 | 登录用户名(唯一) |
| password | varchar | 100 | 否 | 是 | 密码(BCrypt 加密后存储) |
| nickname | varchar | 50 | 否 | 否 | 用户昵称 |
| status | tinyint | 1 | 否 | 是 | 状态(0 - 禁用,1 - 正常) |
| create_time | datetime | - | 否 | 是 | 创建时间 |
| update_time | datetime | - | 否 | 否 | 更新时间 |
2. 角色表(sys_role)
比如 “ADMIN”“USER”“GUEST”,角色是权限的集合
| 字段名 | 类型 | 长度 | 主键 | 非空 | 注释 |
|---|---|---|---|---|---|
| id | bigint | - | 是 | 是 | 角色 ID(自增) |
| role_name | varchar | 50 | 否 | 是 | 角色名称(如:管理员) |
| role_code | varchar | 50 | 否 | 是 | 角色编码(如:ADMIN,唯一) |
| description | varchar | 200 | 否 | 否 | 角色描述 |
| create_time | datetime | - | 否 | 是 | 创建时间 |
3. 权限表(sys_permission)
绑定接口路径,比如/api/admin/*需要 “系统管理” 权限
| 字段名 | 类型 | 长度 | 主键 | 非空 | 注释 |
|---|---|---|---|---|---|
| id | bigint | - | 是 | 是 | 权限 ID(自增) |
| perm_name | varchar | 50 | 否 | 是 | 权限名称(如:用户查询) |
| perm_code | varchar | 50 | 否 | 是 | 权限编码(如:USER_QUERY) |
| url | varchar | 200 | 否 | 是 | 接口路径(如:/api/user/info) |
| method | varchar | 10 | 否 | 否 | 请求方法(GET/POST,空则不限) |
| create_time | datetime | - | 否 | 是 | 创建时间 |
4. 用户 - 角色关联表(sys_user_role)
多对多关系,一个用户可以有多个角色
| 字段名 | 类型 | 长度 | 主键 | 非空 | 注释 |
|---|---|---|---|---|---|
| id | bigint | - | 是 | 是 | 关联 ID(自增) |
| user_id | bigint | - | 否 | 是 | 用户 ID(关联 sys_user.id) |
| role_id | bigint | - | 否 | 是 | 角色 ID(关联 sys_role.id) |
5. 角色 - 权限关联表(sys_role_permission)
多对多关系,一个角色可以有多个权限
| 字段名 | 类型 | 长度 | 主键 | 非空 | 注释 |
|---|---|---|---|---|---|
| id | bigint | - | 是 | 是 | 关联 ID(自增) |
| role_id | bigint | - | 否 | 是 | 角色 ID(关联 sys_role.id) |
| perm_id | bigint | - | 否 | 是 | 权限 ID(关联 sys_permission.id) |
四、核心代码实现:从 Token 工具到拦截器
这部分是重点,我按 “工具类→接口→拦截器→配置” 的顺序写,逻辑清晰,新手也能跟上。
1. JWT 工具类:生成 / 解析 Token
Token 的核心是 “签名”,密钥要存在配置文件里,别硬编码!过期时间也建议配置,方便后续调整。
java
import io.jsonwebtoken.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
public class JwtUtils {
// 从配置文件读取密钥(application.yml里配)
@Value("${jwt.secret}")
private String secret;
// AccessToken过期时间:2小时(单位:毫秒)
@Value("${jwt.access-token-expire}")
private long accessTokenExpire;
// 生成Token:传入userId和username(Token里只存必要信息,别存密码!)
public String generateToken(Long userId, String username) {
// 1. 准备Token的 payload(自定义信息)
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
// 2. 生成Token
return Jwts.builder()
.setClaims(claims) // 自定义信息
.setIssuedAt(new Date()) // 签发时间
.setExpiration(new Date(System.currentTimeMillis() + accessTokenExpire)) // 过期时间
.signWith(SignatureAlgorithm.HS256, secret) // 签名算法+密钥
.compact();
}
// 解析Token:获取里面的userId
public Long getUserIdFromToken(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
return claims.get("userId", Long.class);
} catch (Exception e) {
log.error("解析Token失败:{}", e.getMessage());
return null; // 解析失败返回null,后续拦截器会处理
}
}
// 验证Token是否有效(没过期、签名正确)
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.error("Token已过期");
} catch (UnsupportedJwtException e) {
log.error("Token格式不支持");
} catch (InvalidClaimException e) {
log.error("Token信息无效");
} catch (Exception e) {
log.error("Token验证失败");
}
return false;
}
}
2. 登录接口:生成 Token 的入口
登录逻辑很简单:查用户→验密码→给 Token。注意密码用 BCrypt 比对,数据库里存的是加密后的字符串。
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor // Lombok的注解,替代@Autowired
public class AuthController {
private final SysUserService userService;
private final JwtUtils jwtUtils;
private final BCryptPasswordEncoder passwordEncoder; // Spring Security的加密器
// 登录接口:POST /api/auth/login
@PostMapping("/login")
public Result login(@RequestBody LoginDTO loginDTO) {
// 1. 查用户(按用户名)
SysUser user = userService.getOne(
new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, loginDTO.getUsername())
);
// 2. 验用户是否存在、是否禁用
if (user == null) {
return Result.fail("用户名不存在");
}
if (user.getStatus() == 0) {
return Result.fail("账号已禁用");
}
// 3. 验密码(BCrypt比对:明文密码 vs 数据库加密后的密码)
if (!passwordEncoder.matches(loginDTO.getPassword(), user.getPassword())) {
return Result.fail("密码错误");
}
// 4. 生成Token
String token = jwtUtils.generateToken(user.getId(), user.getUsername());
// 5. 返回结果(Token放data里,前端存LocalStorage)
Map<String, String> data = new HashMap<>();
data.put("token", token);
data.put("nickname", user.getNickname());
return Result.success("登录成功", data);
}
}
// 配套的DTO和Result类(前后端交互用)
// LoginDTO:接收前端传入的用户名密码
@Data
public class LoginDTO {
private String username;
private String password;
}
// Result:统一响应格式(所有接口都用这个)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result {
private Integer code; // 0-成功,1-失败
private String msg;
private Object data;
// 静态方法:简化调用
public static Result success(String msg, Object data) {
return new Result(0, msg, data);
}
public static Result fail(String msg) {
return new Result(1, msg, null);
}
}
3. 两个核心拦截器:Token 校验 + 权限校验
拦截器是 “守门人”,先验 Token 是否有效,再验用户有没有权限访问接口。注意拦截器要按顺序执行:先验 Token,再验权限。
(1)Token 拦截器:验证 Token 是否存在、有效
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
@Component
@RequiredArgsConstructor
public class TokenInterceptor implements HandlerInterceptor {
private final JwtUtils jwtUtils;
// 接口调用前执行(返回true继续,false拦截)
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从请求头获取Token(前端要把Token放在Authorization里,格式:Bearer xxx)
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
// 没传Token,返回401
returnJson(response, Result.fail("请先登录"));
return false;
}
// 2. 截取Token(去掉“Bearer ”前缀)
token = token.substring(7);
// 3. 验证Token是否有效
if (!jwtUtils.validateToken(token)) {
returnJson(response, Result.fail("Token已过期或无效,请重新登录"));
return false;
}
// 4. Token有效:把userId存到请求属性里,后续权限校验用
Long userId = jwtUtils.getUserIdFromToken(token);
request.setAttribute("userId", userId);
// 继续执行(进入下一个拦截器或接口)
return true;
}
// 响应JSON给前端(拦截器里不能直接return Result,要手动写响应)
private void returnJson(HttpServletResponse response, Result result) throws Exception {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json; charset=utf-8");
PrintWriter writer = response.getWriter();
writer.write(JSON.toJSONString(result)); // fastjson的序列化
writer.flush();
}
}
(2)权限拦截器:验证用户是否有权访问接口
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
@Component
@RequiredArgsConstructor
public class PermissionInterceptor implements HandlerInterceptor {
private final SysUserService userService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从请求属性里获取userId(Token拦截器已经存好了)
Long userId = (Long) request.getAttribute("userId");
if (userId == null) {
returnJson(response, Result.fail("用户信息获取失败"));
return false;
}
// 2. 获取当前请求的路径和方法(比如:/api/user/info,GET)
String requestUrl = request.getRequestURI();
String requestMethod = request.getMethod();
// 3. 查用户的所有权限(通过用户→角色→权限的关联)
List<String> userPermUrls = userService.getUserPermUrls(userId);
// 4. 权限校验:判断当前请求的url是否在用户的权限列表里
// (这里简化处理,实际项目可以加通配符匹配,比如 /api/admin/*)
boolean hasPermission = userPermUrls.stream()
.anyMatch(permUrl -> permUrl.equals(requestUrl)
&& (permUrl.getMethod() == null || permUrl.getMethod().equals(requestMethod)));
if (!hasPermission) {
returnJson(response, Result.fail("没有访问权限(403)"));
return false;
}
return true;
}
// 复用Token拦截器的returnJson方法(也可以抽成工具类)
private void returnJson(HttpServletResponse response, Result result) throws Exception {
// 代码和TokenInterceptor里的一样,这里省略
}
}
4. 全局配置:注册拦截器 + 跨域
所有拦截器和配置都要汇总到一个 Config 类里,用WebMvcConfigurer来注册拦截器,指定拦截哪些路径、排除哪些路径(比如登录接口不用拦截)。
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final TokenInterceptor tokenInterceptor;
private final PermissionInterceptor permissionInterceptor;
// 1. 注册拦截器:设置拦截顺序和路径
@Override
public void addInterceptors(InterceptorRegistry registry) {
// ① Token拦截器:拦截所有/api/**路径,排除登录接口
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/api/**") // 拦截所有/api开头的接口
.excludePathPatterns("/api/auth/login") // 登录接口不拦截
.excludePathPatterns("/api/auth/register"); // 注册接口也不拦截
// ② 权限拦截器:只拦截需要权限的路径(比如/admin/**、/user/**)
// 注意:addPathPatterns要比Token拦截器更具体,避免重复拦截
registry.addInterceptor(permissionInterceptor)
.addPathPatterns("/api/admin/**") // 管理员接口
.addPathPatterns("/api/user/**") // 用户接口
.excludePathPatterns("/api/user/info"); // 比如“获取用户信息”不需要权限(根据业务调整);
}
// 2. 跨域配置:前后端分离必配,否则前端调接口会报CORS错误
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 所有路径允许跨域
.allowedOrigins("http://localhost:8080") // 前端域名(生产环境要改实际域名)
.allowedMethods("GET", "POST", "PUT", "DELETE") // 允许的请求方法
.allowedHeaders("*") // 允许的请求头
.allowCredentials(true) // 允许携带Cookie(如果需要)
.maxAge(3600); // 预检请求的有效期(秒)
}
// 3. 注入BCrypt加密器:登录时用它比对密码
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
5. 配置文件(application.yml)
把 JWT 密钥、数据库信息等配置放这里,方便修改:
# 服务器配置
server:
port: 8081
servlet:
context-path: /
# 数据库配置
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
username: root
password: 123456
# MyBatis-Plus配置
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.example.securitydemo.entity
configuration:
map-underscore-to-camel-case: true # 下划线转驼峰
# JWT配置
jwt:
secret: oldZhouJava8Years # 密钥(生产环境要改复杂点,比如用UUID,存在配置中心)
access-token-expire: 7200000 # 2小时(7200*1000毫秒)
五、接口测试:Postman 实战
代码写完要测,这里给几个关键接口的测试案例,确保流程通了:
| 接口地址 | 请求方法 | 请求头 | 请求体(JSON) | 预期响应 |
|---|---|---|---|---|
| /api/auth/login | POST | Content-Type: application/json | {"username":"admin","password":"123456"} | 成功:code=0,data 里有 token |
| /api/user/info | GET | Authorization: Bearer {token} | 无 | 成功:返回用户昵称、ID 等信息 |
| /api/admin/list | GET | Authorization: Bearer {token} | 无 | 有 ADMIN 权限:返回用户列表;无权限:code=1,msg = 没有访问权限 |
| /api/auth/login | POST | Content-Type: application/json | {"username":"admin","password":"12345"} | 失败:code=1,msg = 密码错误 |
六、八年经验踩坑总结:这些坑别踩!
- Token 密钥别硬编码:我见过有人把密钥写在工具类里,上线后忘了改,被黑客破解了 Token。一定要放配置文件,生产环境用配置中心(Nacos/Apollo)管理。
- 拦截器顺序别搞反:必须先验 Token,再验权限 —— 如果先验权限,此时 userId 还没从 Token 里解析出来,权限校验会报错。
- 权限路径别写太宽泛:比如
/api/**都加权限拦截,会导致像 “获取验证码” 这样的接口也被拦截,要按业务拆分路径(如/api/admin/**、/api/user/**)。 - Token 过期处理:只给一个 AccessToken 不够,生产环境要加 RefreshToken(过期时间 7 天),前端拿到 401 后,用 RefreshToken 换新的 AccessToken,避免用户频繁登录。
- BCrypt 加密别自己造轮子:有人觉得 BCrypt 麻烦,自己写 MD5 + 盐,结果盐值存在代码里,被脱库后密码全泄露了。Spring Security 自带的 BCrypt 足够安全,直接用。
七、最后总结
这套方案是我从实际项目里提炼的 “最小可用版”,涵盖了登录、认证、权限、拦截器的核心逻辑,没有多余的依赖,新手也能快速上手。
如果是微服务项目,还可以把权限校验抽到网关(Gateway)里,用全局过滤器替代拦截器,这样所有服务都不用重复写拦截器了 —— 不过那是进阶内容,下次再跟大家聊。