Skip to content

登录与鉴权流程(安全基石)

基于模块: security-spring-boot-starter + wemirr-platform-iam 认证框架: Sa-Token 1.45.0


核心组件

组件文件作用
TokenControllerTokenController.java登录接口
AuthenticatorStrategyTemplateAuthenticatorStrategyTemplate.java认证策略模板
UsernamePasswordAuthenticatorStrategyUsernamePasswordAuthenticatorStrategy.java用户名密码认证策略
PasswordEncoderHelperPasswordEncoderHelper.java密码加密工具
OAuth2AutoConfigurationOAuth2AutoConfiguration.javaSa-Token 拦截器配置
WpTokenListenerWpTokenListener.java登录监听器
StpInterfaceRedisImplStpInterfaceRedisImpl.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:

java
{
    "tenantCode": "0000",           // 租户编码(必填)
    "username": "admin",            // 用户名(必填)
    "password": "123456",           // 密码(必填)
    "loginType": "password",        // 登录类型(必填)
    "code": "123456",               // 验证码(可选)
    "clientId": "pc-web",           // 客户端ID(必填)
    "clientSecret": "pc-web"        // 客户端密钥(必填)
}

代码位置: TokenController.java:69-85

java
@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

java
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

java
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策略类说明
passwordUsernamePasswordAuthenticatorStrategy用户名密码登录
sliderSliderCodeAuthenticatorStrategy滑块验证码登录
giteeGiteeAuthenticatorStrategyGitee 第三方登录

第四步:用户名密码认证

代码位置: UsernamePasswordAuthenticatorStrategy.java:61-79

java
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 哈希

加密:

java
// 默认使用 BCrypt
String encodedPassword = PasswordEncoderHelper.encode("123456");
// 结果: {bcrypt}$2a$10$xxxxx...

比对:

java
// rawPassword: 用户输入的明文密码
// encodedPassword: 数据库存储的加密密码
boolean matches = PasswordEncoderHelper.matches(rawPassword, encodedPassword);

实现原理:

java
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

java
StpUtil.login(user.getId(), principal.getClientId());

Sa-Token 处理流程:

  1. 生成唯一 Token(默认 UUID)
  2. 存储 Token-UserId 映射到 Redis
  3. 设置过期时间
  4. 触发 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

java
@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

java
@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:

java
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 校验流程:

java
StpUtil.checkLogin();
  1. 从 Header/Cookie 获取 Token
  2. 检查 Redis 中是否存在
  3. 检查是否过期
  4. 检查是否被踢下线
  5. 通过后设置 AuthenticationContext

第九步:权限校验

权限接口: StpInterfaceRedisImpl.java

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();
    }
}

使用注解校验权限:

java
@SaCheckPermission("user:add")
public void addUser(UserDTO dto) {
    // 只有拥有 user:add 权限的用户才能访问
}

数据库表结构

1. tenant - 租户表

字段类型说明
idbigint主键
codevarchar租户编码(如 0000)
namevarchar租户名称
statusboolean状态
db_idbigint数据库实例ID

2. user - 用户表(租户库)

字段类型说明
idbigint主键
usernamevarchar用户名
passwordvarchar加密密码
statusboolean状态
tenant_idbigint租户ID
last_login_ipvarchar最后登录IP
last_login_timedatetime最后登录时间

3. registered_client - 客户端表

字段类型说明
idbigint主键
client_idvarchar客户端ID
client_secretvarchar客户端密钥
statusboolean状态
client_id_issued_atdatetime生效时间
client_secret_expires_atdatetime失效时间

4. login_log - 登录日志表(租户库)

字段类型说明
idbigint主键
tenant_idbigint租户ID
principalvarchar登录账号
client_idvarchar客户端ID
ipvarcharIP地址
locationvarchar地区
browservarchar浏览器
osvarchar操作系统
create_timedatetime登录时间

关键配置

application.yml:

yaml
# 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查询租户、用户、密码比对
密码比对PasswordEncoderHelperBCrypt 密码验证
Token 生成StpUtil.login()生成 Token 存入 Redis
登录监听WpTokenListener.doLogin()记录登录日志
请求拦截OAuth2AutoConfigurationSa-Token 拦截器
Token 校验StpUtil.checkLogin()校验 Token 有效性
权限校验StpInterfaceRedisImpl获取权限列表

常见问题 Q&A

Q1: "选择认证策略"是什么意思?

这是设计模式中的策略模式,根据前端传来的 loginType 参数,自动选择不同的登录认证逻辑。

策略接口 AuthenticatorStrategy.java:

java
public interface AuthenticatorStrategy {
    // 判断是否支持该登录类型
    default boolean support(String loginType) {
        return loginType != null && loginType.equalsIgnoreCase(loginType());
    }

    // 返回该策略支持的登录类型
    String loginType();
}

目前支持的登录策略:

loginType策略类说明
passwordUsernamePasswordAuthenticatorStrategy用户名+密码登录
sliderSliderCodeAuthenticatorStrategy滑块验证码+密码登录
giteeGiteeAuthenticatorStrategyGitee 第三方授权登录

策略选择逻辑:

java
// 根据 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 校验逻辑:

java
StpUtil.checkLogin();
  1. 从 Header/Cookie 获取 Token
  2. 检查 Redis 中是否存在
  3. 检查是否过期
  4. 检查是否被踢下线
  5. 通过后设置 AuthenticationContext

Q3: 白名单在哪里看的?在哪里配置?

白名单在两个地方:

1. 代码中的默认白名单

文件: SecurityExtProperties.java

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:

yaml
extend:
  security:
    ignore:
      resource-urls:
        - /api/public/**
        - /api/health
        - /api/test/**
方式位置说明
查看默认白名单SecurityExtProperties.java:45-48代码写死的默认路径
添加自定义白名单application.yml配置文件中添加
运行时查看启动日志或断点调试查看 OAuth2AutoConfiguration

Q4: 添加了一个新的业务接口,不需要登录,应该怎么配置?

有两种方式:

方式1:使用注解(推荐单个接口)

@SaIgnore - 跳过当前接口的登录校验

java
@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:

yaml
extend:
  security:
    ignore:
      resource-urls:
        - /api/public/**    # 所有公开接口
        - /api/health       # 健康检查

对比

方式优点缺点适用场景
@SaIgnore精确控制,代码清晰每个接口都要加少量公开接口
白名单统一管理,支持通配符所有服务都要配置一类接口(如 /api/public/**

推荐选择:

  • 单个接口 → @SaIgnore
  • 一类接口 → 白名单配置

相关文档