登录与鉴权流程(安全基石)
基于模块:
security-spring-boot-starter+wemirr-platform-iam认证框架: Sa-Token 1.45.0
核心组件
| 组件 | 文件 | 作用 |
|---|---|---|
TokenController | TokenController.java | 登录接口 |
AuthenticatorStrategyTemplate | AuthenticatorStrategyTemplate.java | 认证策略模板 |
UsernamePasswordAuthenticatorStrategy | UsernamePasswordAuthenticatorStrategy.java | 用户名密码认证策略 |
PasswordEncoderHelper | PasswordEncoderHelper.java | 密码加密工具 |
OAuth2AutoConfiguration | OAuth2AutoConfiguration.java | Sa-Token 拦截器配置 |
WpTokenListener | WpTokenListener.java | 登录监听器 |
StpInterfaceRedisImpl | StpInterfaceRedisImpl.java | 权限接口实现 |
完整登录流程时序图
┌─────────┐ ┌────────────────┐ ┌──────────────────────────┐ ┌──────────────┐
│ 前端 │ │ TokenController│ │ AuthenticatorStrategy │ │ 数据库 │
└────┬────┘ └───────┬────────┘ └──────────┬───────────────┘ └──────┬───────┘
│ │ │ │
│ 1. POST /token/login │ │
│ {tenantCode, username, password} │ │
│─────────────────>│ │ │
│ │ 2. build AuthenticationPrincipal │
│ │───────────────────────>│ │
│ │ │ 3. prepare() 验证参数 │
│ │ │ - 租户编码 │
│ │ │ - 用户名 │
│ │ │ - 密码 │
│ │ │ - 客户端ID/Secret │
│ │ │──────────┐ │
│ │ │ │ 4. 查询客户端 │
│ │ │<─────────┤ registered_client
│ │ │ │ │
│ │ 5. authenticate() │ │──────────────>│
│ │──────────────────────>│ │ │
│ │ │ 6. 选择策略 │
│ │ │ (UsernamePassword) │
│ │ │ │ 7. 查询租户 │
│ │ │ │──────────────>│
│ │ │<─────────┤ │
│ │ │ 8. 校验租户状态 │
│ │ │ │ 9. 查询用户 │
│ │ │ │──────────────>│
│ │ │<─────────┤ │
│ │ │ 10. 校验用户状态 │
│ │ │ 11. 密码比对 │
│ │ │ BCrypt.checkpw() │
│ │ │──────────┐ │
│ │ │ │ │
│ │ 12. 返回 UserTenantAuthentication │
│ │<─────────────────────│ │ │
│ 13. StpUtil.login(userId) │ │ │
│ │──────────────────────>│ │ │
│ │ │ │ 14. 触发登录事件│
│ │ │ │──────────────>│
│ │ │ │ 15. 记录登录日志│
│ │ │ │ 16. 更新最后登录│
│ │ │ │<──────────────┤
│ │ │ 17. 生成 Token │
│ │ │ (存入 Redis) │
│ 18. 返回 Token │ │ │ │
│<─────────────────│ │ │ │
│ │ │ │ │
│ 19. 后续请求携带 Token │ │ │
│─────────────────┼──────────────────────┼──────────┴───────────────┤
│ │ │ │
│ │ SaInterceptor 拦截器校验 │
│ │─────────────────────────────────────────────────>│
│ │ │ │
│ │ 20. StpUtil.checkLogin() │
│ │ 校验 Token 是否有效 │
│ │ - Redis 中是否存在 │
│ │ - 是否过期 │
│ │ │ │流程详解
第一步:登录请求
接口: POST /token/login
请求体 LoginReq.java:
{
"tenantCode": "0000", // 租户编码(必填)
"username": "admin", // 用户名(必填)
"password": "123456", // 密码(必填)
"loginType": "password", // 登录类型(必填)
"code": "123456", // 验证码(可选)
"clientId": "pc-web", // 客户端ID(必填)
"clientSecret": "pc-web" // 客户端密钥(必填)
}代码位置: TokenController.java:69-85
@PostMapping("/login")
public LoginResp login(HttpServletRequest request, @Validated @RequestBody LoginReq req) {
// 1. 构建认证主体
AuthenticationPrincipal principal = AuthenticationPrincipal.builder()
.code(req.getCode())
.loginType(req.getLoginType())
.tenantCode(req.getTenantCode())
.clientId(req.getClientId())
.clientSecret(req.getClientSecret())
.username(req.getUsername())
.password(req.getPassword())
.request(request)
.build();
// 2. 准备阶段(验证参数)
strategyTemplate.prepare(principal);
// 3. 执行认证
strategyTemplate.authenticate(principal);
// 4. 获取 Token 信息
SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
// 5. 返回 Token
return LoginResp.builder()
.accessToken(tokenInfo.getTokenValue())
.expiresIn(tokenInfo.getTokenTimeout())
.clientId(principal.getClientId())
.tokenType(tokenConfig.getTokenPrefix())
.build();
}第二步:参数验证 (prepare)
代码位置: AuthenticatorStrategyTemplate.java:53-73
public void prepare(final AuthenticationPrincipal principal) {
// 1. 参数非空校验
Assert.notBlank(principal.getTenantCode(), () -> CheckedException.badRequest("租户编码不能为空"));
Assert.notBlank(principal.getUsername(), () -> CheckedException.badRequest("用户名不能为空"));
Assert.notBlank(principal.getPassword(), () -> CheckedException.badRequest("密码不能为空"));
Assert.notBlank(principal.getClientId(), () -> CheckedException.badRequest("客户端ID不能为空"));
Assert.notBlank(principal.getClientSecret(), () -> CheckedException.badRequest("客户端秘钥不能为空"));
// 2. 查询客户端信息
RegisteredClient registeredClient = Optional.ofNullable(
this.registeredClientMapper.selectOne(Wraps.<RegisteredClient>lbQ()
.eq(RegisteredClient::getClientId, principal.getClientId())
.eq(RegisteredClient::getClientSecret, principal.getClientSecret()))
).orElseThrow(() -> CheckedException.notFound("未查询到有效的客户端信息"));
// 3. 校验客户端状态
Assert.isTrue(registeredClient.getStatus(), () -> CheckedException.badRequest("当前客户端提被禁用"));
// 4. 校验客户端有效期
Instant issuedAt = registeredClient.getClientIdIssuedAt();
Instant expiresAt = registeredClient.getClientSecretExpiresAt();
Assert.isTrue(issuedAt == null || issuedAt.isBefore(Instant.now()),
() -> CheckedException.badRequest("客户端秘钥未生效"));
Assert.isTrue(expiresAt == null || expiresAt.isBefore(Instant.now()),
() -> CheckedException.badRequest("客户端秘钥已失效"));
// 5. 存储到 Sa-Token Storage
SaHolder.getStorage()
.set(AuthenticationPrincipal.PRINCIPAL, principal.getUsername())
.set(AuthenticationPrincipal.PRINCIPAL_TYPE, principal.getLoginType());
}数据库查询: registered_client 表
| 字段 | 说明 |
|---|---|
| client_id | 客户端ID |
| client_secret | 客户端密钥 |
| status | 状态(true=启用) |
| client_id_issued_at | 生效时间 |
| client_secret_expires_at | 失效时间 |
第三步:认证策略选择
代码位置: AuthenticatorStrategyTemplate.java:75-90
public void authenticate(AuthenticationPrincipal principal) {
// 1. 根据 loginType 选择策略
AuthenticatorStrategy strategy = authenticatorStrategies.stream()
.filter(x -> x.support(principal.getLoginType()))
.findFirst()
.orElseThrow(() -> CheckedException.notFound("未检测到有效的策略"));
// 2. 前置处理
strategy.prepare(principal);
// 3. 执行认证
UserTenantAuthentication authentication = strategy.authenticate(principal);
// 4. 存储认证信息
User user = authentication.getUser();
if (user != null) {
SaHolder.getStorage().set(AuthenticationPrincipal.AUTHENTICATION, authentication);
// 5. Sa-Token 登录(生成 Token)
StpUtil.login(user.getId(), principal.getClientId());
}
// 6. 后置处理
strategy.complete(principal);
}策略类型:
| loginType | 策略类 | 说明 |
|---|---|---|
password | UsernamePasswordAuthenticatorStrategy | 用户名密码登录 |
slider | SliderCodeAuthenticatorStrategy | 滑块验证码登录 |
gitee | GiteeAuthenticatorStrategy | Gitee 第三方登录 |
第四步:用户名密码认证
代码位置: UsernamePasswordAuthenticatorStrategy.java:61-79
public UserTenantAuthentication authenticate(final AuthenticationPrincipal principal) {
String username = principal.getUsername();
String password = principal.getPassword();
String tenantCode = principal.getTenantCode();
// 1. 查询租户(使用主库)
Tenant tenant = Optional.ofNullable(
TenantHelper.executeWithMaster(() ->
tenantMapper.selectOne(Tenant::getCode, tenantCode))
).orElseThrow(() -> CheckedException.notFound("{0}租户不存在", tenantCode));
// 2. 校验租户状态
if (!tenant.getStatus()) {
throw CheckedException.badRequest("租户已被禁用,请联系管理员");
}
// 3. 查询用户(切换到租户库)
User user = Optional.ofNullable(
TenantHelper.executeWithTenantDb(tenantCode, () ->
userMapper.selectUserByTenantId(username, tenant.getId()))
).orElseThrow(() -> CheckedException.notFound("账户不存在"));
// 4. 校验用户状态
if (user.getStatus() == null || !user.getStatus()) {
throw CheckedException.badRequest("用户已被禁用");
}
// 5. 密码比对
if (!PasswordEncoderHelper.matches(password, user.getPassword())) {
throw CheckedException.badRequest("用户名或密码错误");
}
return UserTenantAuthentication.builder()
.user(user)
.tenant(tenant)
.build();
}第五步:密码加密与比对
代码位置: PasswordEncoderHelper.java
加密算法支持:
| 算法 | 前缀 | 说明 |
|---|---|---|
| BCrypt | {bcrypt} | 默认算法,加盐哈希 |
| MD5 | {md5} | MD5 哈希 |
| SHA256 | {sha256} | SHA256 哈希 |
加密:
// 默认使用 BCrypt
String encodedPassword = PasswordEncoderHelper.encode("123456");
// 结果: {bcrypt}$2a$10$xxxxx...比对:
// rawPassword: 用户输入的明文密码
// encodedPassword: 数据库存储的加密密码
boolean matches = PasswordEncoderHelper.matches(rawPassword, encodedPassword);实现原理:
public static boolean matches(String rawPassword, String encodedPassword) {
// 1. 提取加密算法标识
String encodingId = StrUtil.subBetween(encodedPassword, "{", "}");
// 2. 提取实际加密后的密码
String actualPassword = StrUtil.removePrefix(
encodedPassword, "{" + encodingId + "}");
// 3. 根据算法类型进行比对
return switch (encodingId) {
case "bcrypt" -> BCrypt.checkpw(rawPassword, actualPassword);
case "md5" -> SaSecureUtil.md5(rawPassword).equals(actualPassword);
case "sha256" -> SaSecureUtil.sha256(rawPassword).equals(actualPassword);
default -> rawPassword.equals(actualPassword);
};
}数据库存储格式:
password: {bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
└─算法──┘└────────── BCrypt hash ──────────────┘第六步:Token 生成
代码位置: AuthenticatorStrategyTemplate.java:86
StpUtil.login(user.getId(), principal.getClientId());Sa-Token 处理流程:
- 生成唯一 Token(默认 UUID)
- 存储 Token-UserId 映射到 Redis
- 设置过期时间
- 触发
SaTokenListener.doLogin()事件
Redis 存储结构:
# Token -> UserID 映射
satoken:login:token:tokenValue -> userId
# UserID -> TokenSet 映射
satoken:login:session:userId -> {token1, token2, ...}
# Token 会话数据
satoken:login:token:tokenValue -> {userId, clientId, ...}第七步:登录监听器
代码位置: WpTokenListener.java:69-90
@Override
public void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter) {
// 1. 获取请求信息
String ip = JakartaServletUtil.getClientIP(request);
String region = RegionUtils.getRegion(ip);
String ua = request.getHeader("User-Agent");
var userAgent = NativeUserAgent.parse(ua);
// 2. 获取用户信息
UserTenantAuthentication authentication =
SaHolder.getStorage().getModel(AuthenticationPrincipal.AUTHENTICATION, UserTenantAuthentication.class);
UserInfoDetails info = this.userService.userinfo(authentication);
// 3. 构建登录日志
LoginLog loginLog = LoginLog.builder()
.principal(principal)
.clientId(loginParameter.getDeviceType())
.tenantId(info.getTenantId())
.tenantCode(info.getTenantCode())
.location(region)
.ip(ip)
.platform(userAgent.platform())
.engine(userAgent.engine())
.browser(userAgent.browser())
.os(userAgent.os())
.loginType(principalType)
.createBy(userId)
.createTime(Instant.now())
.createName(info.getNickname())
.build();
// 4. 存储用户信息到 Token Session
StpUtil.getTokenSessionByToken(tokenValue)
.set(extProperties.getServer().getTokenInfoKey(), info);
// 5. 记录登录日志(切换到租户库)
TenantHelper.executeWithTenantDb(info.getTenantCode(), () ->
this.loginLogMapper.insert(loginLog));
// 6. 更新用户最后登录信息
this.userService.updateById(User.builder()
.id(userId)
.lastLoginIp(ip)
.lastLoginTime(Instant.now())
.build());
}记录的数据:
- IP 地址
- 地区(根据 IP 解析)
- 浏览器信息
- 操作系统
- 登录时间
第八步:请求拦截与 Token 校验
拦截器配置: OAuth2AutoConfiguration.java:48-70
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> {
// 兼容 SSE 异步回调
if (request != null && request.getDispatcherType() == DispatcherType.ASYNC) {
return;
}
// 拦截所有请求,排除白名单
SaRouter.match("/**")
.notMatch(extProperties.getDefaultIgnoreUrls())
.notMatch(extProperties.getIgnore().getResourceUrls())
.check(r -> {
// 校验 Same-Token(微服务间调用)
String token = SaHolder.getRequest().getHeader(SaSameUtil.SAME_TOKEN);
if (StrUtil.isNotBlank(token)) {
SaSameUtil.checkToken(token);
} else {
// 校验用户 Token
StpUtil.checkLogin();
}
});
}))
.excludePathPatterns("/error")
.addPathPatterns("/**")
.order(Ordered.HIGHEST_PRECEDENCE);
}白名单路径 SecurityExtProperties.java:45-48:
private List<String> defaultIgnoreUrls = List.of(
"/captcha", "/sms_captcha", "/message/**",
"/login", "/error", "/oauth2/**",
"/warm-flow-ui/**", "/warm-flow/**", "/flow/**",
"/favicon.ico", "/css/**", "/webjars/**",
"/swagger-ui.html", "/doc.html", "/v3/api-docs/**"
);Token 校验流程:
StpUtil.checkLogin();- 从 Header/Cookie 获取 Token
- 检查 Redis 中是否存在
- 检查是否过期
- 检查是否被踢下线
- 通过后设置
AuthenticationContext
第九步:权限校验
权限接口: StpInterfaceRedisImpl.java
@Component
@RequiredArgsConstructor
public class StpInterfaceRedisImpl implements StpInterface {
private final AuthenticationContext context;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return context.funcPermissionList();
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
return context.rolePermissionList();
}
}使用注解校验权限:
@SaCheckPermission("user:add")
public void addUser(UserDTO dto) {
// 只有拥有 user:add 权限的用户才能访问
}数据库表结构
1. tenant - 租户表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| code | varchar | 租户编码(如 0000) |
| name | varchar | 租户名称 |
| status | boolean | 状态 |
| db_id | bigint | 数据库实例ID |
2. user - 用户表(租户库)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| username | varchar | 用户名 |
| password | varchar | 加密密码 |
| status | boolean | 状态 |
| tenant_id | bigint | 租户ID |
| last_login_ip | varchar | 最后登录IP |
| last_login_time | datetime | 最后登录时间 |
3. registered_client - 客户端表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| client_id | varchar | 客户端ID |
| client_secret | varchar | 客户端密钥 |
| status | boolean | 状态 |
| client_id_issued_at | datetime | 生效时间 |
| client_secret_expires_at | datetime | 失效时间 |
4. login_log - 登录日志表(租户库)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| tenant_id | bigint | 租户ID |
| principal | varchar | 登录账号 |
| client_id | varchar | 客户端ID |
| ip | varchar | IP地址 |
| location | varchar | 地区 |
| browser | varchar | 浏览器 |
| os | varchar | 操作系统 |
| create_time | datetime | 登录时间 |
关键配置
application.yml:
# Sa-Token 配置
sa-token:
# Token 名称(也是 Cookie 名称)
token-name: Authorization
# Token 有效期(单位:秒)默认30天
timeout: 2592000
# Token 临时有效期(指定时间内无操作就视为 token 过期)
activity-timeout: -1
# 是否允许同一账号多地同时登录
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token
is-share: false
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
token-style: uuid
# 是否输出操作日志
is-log: false
# 扩展安全配置
extend:
security:
enabled-oauth2: false
server:
token-info-key: tokenInfo总结
| 阶段 | 核心类 | 说明 |
|---|---|---|
| 登录请求 | TokenController | 接收登录参数 |
| 参数验证 | AuthenticatorStrategyTemplate.prepare() | 验证参数、查询客户端 |
| 认证策略 | AuthenticatorStrategyTemplate.authenticate() | 选择认证策略 |
| 用户认证 | UsernamePasswordAuthenticatorStrategy | 查询租户、用户、密码比对 |
| 密码比对 | PasswordEncoderHelper | BCrypt 密码验证 |
| Token 生成 | StpUtil.login() | 生成 Token 存入 Redis |
| 登录监听 | WpTokenListener.doLogin() | 记录登录日志 |
| 请求拦截 | OAuth2AutoConfiguration | Sa-Token 拦截器 |
| Token 校验 | StpUtil.checkLogin() | 校验 Token 有效性 |
| 权限校验 | StpInterfaceRedisImpl | 获取权限列表 |
常见问题 Q&A
Q1: "选择认证策略"是什么意思?
这是设计模式中的策略模式,根据前端传来的 loginType 参数,自动选择不同的登录认证逻辑。
策略接口 AuthenticatorStrategy.java:
public interface AuthenticatorStrategy {
// 判断是否支持该登录类型
default boolean support(String loginType) {
return loginType != null && loginType.equalsIgnoreCase(loginType());
}
// 返回该策略支持的登录类型
String loginType();
}目前支持的登录策略:
| loginType | 策略类 | 说明 |
|---|---|---|
password | UsernamePasswordAuthenticatorStrategy | 用户名+密码登录 |
slider | SliderCodeAuthenticatorStrategy | 滑块验证码+密码登录 |
gitee | GiteeAuthenticatorStrategy | Gitee 第三方授权登录 |
策略选择逻辑:
// 根据 loginType 从所有策略中找到匹配的
AuthenticatorStrategy strategy = authenticatorStrategies.stream()
.filter(x -> x.support(principal.getLoginType()))
.findFirst()
.orElseThrow(() -> CheckedException.notFound("未检测到有效的策略"));为什么这样设计?
- 开闭原则: 新增登录方式只需添加新策略,无需修改现有代码
- 单一职责: 每个策略只处理一种登录逻辑
- 易于扩展: 未来可轻松添加手机验证码、微信登录等
Q2: Sa-Token 拦截器是做什么的?
简单来说: 它是一个守门员,拦截每个 HTTP 请求,检查用户是否登录(Token 是否有效)。
工作流程:
HTTP 请求
│
▼
Sa-Token 拦截器
│
┌───┴────────┐
│ 白名单? │
└───┬────────┘
│是 │否
放行 ▼
┌────────────────┐
│ 校验 Token │
│ - 有Token? │
│ - Token有效? │
│ - Token过期? │
└────┬───────────┘
│
┌───┴───┐
│ │
放行 拦截(401未登录)代码位置: OAuth2AutoConfiguration.java:48-70
Token 校验逻辑:
StpUtil.checkLogin();- 从 Header/Cookie 获取 Token
- 检查 Redis 中是否存在
- 检查是否过期
- 检查是否被踢下线
- 通过后设置
AuthenticationContext
Q3: 白名单在哪里看的?在哪里配置?
白名单在两个地方:
1. 代码中的默认白名单
文件: SecurityExtProperties.java
@Data
@ConfigurationProperties(prefix = "extend.security")
public class SecurityExtProperties {
// 默认白名单(代码写死的)
private List<String> defaultIgnoreUrls = List.of(
"/captcha", // 验证码
"/sms_captcha", // 短信验证码
"/message/**", // 消息接口
"/login", // 登录接口
"/error", // 错误页面
"/oauth2/**", // OAuth2 授权
"/warm-flow-ui/**", // 流程设计器UI
"/warm-flow/**", // 流程接口
"/flow/**", // 流程接口
"/favicon.ico", // 网站图标
"/css/**", // 样式文件
"/webjars/**", // WebJar 资源
"/swagger-ui.html", // Swagger UI
"/doc.html", // Knife4j 文档
"/v3/api-docs/**" // OpenAPI 文档
);
// 可扩展的白名单(配置文件)
private Ignore ignore = new Ignore();
}2. 配置文件中的自定义白名单
application.yml:
extend:
security:
ignore:
resource-urls:
- /api/public/**
- /api/health
- /api/test/**| 方式 | 位置 | 说明 |
|---|---|---|
| 查看默认白名单 | SecurityExtProperties.java:45-48 | 代码写死的默认路径 |
| 添加自定义白名单 | application.yml | 配置文件中添加 |
| 运行时查看 | 启动日志或断点调试 | 查看 OAuth2AutoConfiguration |
Q4: 添加了一个新的业务接口,不需要登录,应该怎么配置?
有两种方式:
方式1:使用注解(推荐单个接口)
@SaIgnore - 跳过当前接口的登录校验
@RestController
@RequestMapping("/api/public")
public class PublicController {
@SaIgnore // 加上这个注解,不需要登录就能访问
@GetMapping("/hello")
public String hello() {
return "Hello, no login required!";
}
}已在项目中使用的示例:
TokenController.java:68-/login接口TokenController.java:114-/logout接口
方式2:配置白名单(推荐一类接口)
application.yml:
extend:
security:
ignore:
resource-urls:
- /api/public/** # 所有公开接口
- /api/health # 健康检查对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| @SaIgnore | 精确控制,代码清晰 | 每个接口都要加 | 少量公开接口 |
| 白名单 | 统一管理,支持通配符 | 所有服务都要配置 | 一类接口(如 /api/public/**) |
推荐选择:
- 单个接口 →
@SaIgnore - 一类接口 → 白名单配置