feat(sso): 飞书 OAuth2 单点登录 (ISSUE #405 P0) (#419)

* feat(sso): feishu OAuth2 single sign-on (ISSUE #405 P0)

Implements the SSO design (ISSUE #405) with feishu as the first IdP
and a generic OAuth2 provider abstraction for future dingtalk/wecom
extensions. SSO is disabled by default — existing deployments are
unaffected until mateclaw.sso.enabled=true.

Backend:
- SsoProvider interface + SsoUserInfo record: generic IdP abstraction
- FeishuSsoProvider: OAuth2 authorization-code flow (app_access_token
  with Caffeine cache → user_access_token → user info). apiBase switches
  between feishu.cn / larksuite.com by domain config.
- SsoProviderRegistry: conditional registration, lists enabled providers
- SsoStateService: HMAC-signed OAuth2 state + self-contained bind_token
  JWT, both persisted to sso_state DB table for multi-node correctness.
  State is one-time-consumable (conditional UPDATE), bind_token jti
  anti-replay via PK insert. Hourly ShedLock purge (LambdaQuery + Java
  time, works on all 3 dialects).
- SsoService: authorize/callback/bind, user mapping (union_id first →
  external_id fallback), auto-create with concurrent idempotency
  (DuplicateKeyException → rollback orphan user → re-query), link-only
  mode issues bind_token for existing-account binding.
- SsoController: 4 endpoints (/providers, /authorize, /callback, /bind)
  all permitAll.
- V159 migration (h2/mysql/kingbase): mate_user_external_identity,
  sso_state, ALTER mate_user.password NULL (SSO-only users).
- AuthService: generateToken promoted to public; login() guards
  password=null (SSO-only users cannot password-login).
- SecurityConfig: /auth/sso/** added to permitAll whitelist.
- LoginRateLimitFilter: expanded to cover /auth/sso/bind (brute-force
  surface equivalent to /auth/login).
- application.yml: mateclaw.sso.* config block (all env-var driven).

Frontend:
- Login.vue: dynamic SSO buttons (only shown when providers configured),
  OAuth2 callback detection (?sso=callback), link-only bind dialog,
  shared applyLogin flow (localStorage + workspace + route).
- api/index.ts: ssoApi (providers, authorize, callback, bind).

Tests: SsoStateServiceTest (11) — state issue/verify/replay/tamper,
bind_token issue/verify/anti-replay/garbage. Regression: PAT (23) +
Approval resolve (13) all green.

Not in scope (P1/P2): link-only bind/unbind management endpoints,
user enable/disable endpoint, dingtalk/wecom providers, admin SSO
config page. Workspace assignment for auto-created users remains a
product decision (design doc §12 item 2).

* fix(sso): self-review fixes — P0 security + P1 quality

P0-1 BindRequired serialization: replaced the R.fail(200, Map.toString())
hack with a structured SsoCallbackResponse record. Controller no longer
catches an exception for a non-error path; frontend reads bindRequired
flag directly instead of regex-parsing a stringified map.

P0-2 createSsoUser unbounded recursion: added a retry flag — second
DuplicateKeyException (extreme race where identity was concurrently
deleted) now throws a 503 instead of recursing to stack overflow.

P0-3 state TTL not enforced: verifyState's conditional UPDATE now
includes created_at > cutoff, so a state unused for 5+ min is rejected
at consumption time, not just at the 1h purge. Without this the 5-min
window was advisory only.

P1-5 SsoStateService unused ObjectMapper: removed dead injection.

P1-6 audit JSON string concat: replaced with ObjectMapper serialization
(provider/externalId no longer risk breaking the JSON structure).

P1-7 LoginRateLimitFilter shared counter: documented the intentional
decision that login + bind share a per-IP counter (same brute-force
surface) with guidance on switching to per-path if finer isolation
is needed.
This commit is contained in:
倪程伟 2026-06-26 10:00:31 +08:00 committed by GitHub
parent e79fb00fec
commit 03a6d61131
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1764 additions and 27 deletions

View File

@ -50,7 +50,8 @@ public class AuthService {
.eq(UserEntity::getUsername, request.getUsername())
.eq(UserEntity::getEnabled, true));
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
if (user == null || user.getPassword() == null
|| !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
}
@ -212,7 +213,10 @@ public class AuthService {
return userMapper.selectById(userId);
}
private String generateToken(UserEntity user) {
/**
* 生成 JWT tokenSSO 登录路径复用此方法签发格式一致的 token
*/
public String generateToken(UserEntity user) {
return Jwts.builder()
.subject(user.getUsername())
.claim("userId", user.getId())

View File

@ -0,0 +1,33 @@
package vip.mate.auth.sso;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import vip.mate.auth.sso.provider.FeishuSsoProvider;
/**
* SSO 配置启用 {@link SsoProperties} 绑定 + 按需注册飞书 Provider
* <p>
* 仅当 {@code mateclaw.sso.enabled=true} 时此配置生效飞书 Provider 进一步要求
* {@code mateclaw.sso.feishu.enabled=true}
*
* @author MateClaw Team
*/
@Configuration
@EnableScheduling
@EnableConfigurationProperties(SsoProperties.class)
@ConditionalOnProperty(name = "mateclaw.sso.enabled", havingValue = "true")
public class SsoAutoConfiguration {
/**
* 飞书 SSO Provider仅当飞书 SSO 启用时注册
*/
@Bean
@ConditionalOnProperty(name = "mateclaw.sso.feishu.enabled", havingValue = "true")
public FeishuSsoProvider feishuSsoProvider(SsoProperties ssoProperties, ObjectMapper objectMapper) {
return new FeishuSsoProvider(ssoProperties.getFeishu(), objectMapper);
}
}

View File

@ -0,0 +1,43 @@
package vip.mate.auth.sso;
import lombok.AllArgsConstructor;
import lombok.Data;
import vip.mate.auth.model.LoginResponse;
/**
* SSO 回调响应两种互斥形态由 {@code bindRequired} 区分:
* <ul>
* <li>{@code bindRequired=false}: 登录成功, {@code loginResponse} 携带 JWT</li>
* <li>{@code bindRequired=true}: link-only 模式未绑定, {@code bindToken} 供前端引导绑定</li>
* </ul>
*
* <p>替代了原先用 {@code R.fail(200, Map.toString())} 传递绑定信号的 hack
*
* @author MateClaw Team
*/
@Data
@AllArgsConstructor
public class SsoCallbackResponse {
/** link-only 模式下未绑定时为 true */
private boolean bindRequired;
/** 登录成功时非空 */
private LoginResponse loginResponse;
/** bindRequired=true 时非空, 供前端调 /sso/bind */
private String bindToken;
private String provider;
private String displayName;
/** 登录成功响应工厂 */
public static SsoCallbackResponse of(LoginResponse loginResponse) {
return new SsoCallbackResponse(false, loginResponse, null, null, null);
}
/** 需绑定响应工厂 (link-only 模式) */
public static SsoCallbackResponse bindRequired(String bindToken, String provider, String displayName) {
return new SsoCallbackResponse(true, null, bindToken, provider, displayName);
}
}

View File

@ -0,0 +1,70 @@
package vip.mate.auth.sso;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import vip.mate.auth.model.LoginResponse;
import vip.mate.auth.sso.provider.SsoProviderRegistry;
import vip.mate.common.result.R;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* SSO 单点登录 HTTP 端点全部 permitAll ( /auth/login 同级)
*
* @author MateClaw Team
*/
@Tag(name = "SSO 单点登录")
@Slf4j
@RestController
@RequestMapping("/api/v1/auth/sso")
@RequiredArgsConstructor
public class SsoController {
private final SsoProviderRegistry registry;
private final SsoService ssoService;
@Operation(summary = "列出已启用的 SSO Provider")
@GetMapping("/providers")
public R<List<Map<String, String>>> providers() {
List<Map<String, String>> list = registry.listEnabled().stream()
.map(p -> Map.of("id", p.id(), "displayName", p.displayName()))
.collect(Collectors.toList());
return R.ok(list);
}
@Operation(summary = "获取 SSO 授权 URL")
@GetMapping("/{provider}/authorize")
public R<Map<String, String>> authorize(@PathVariable String provider) {
return R.ok(ssoService.handleAuthorize(provider));
}
@Operation(summary = "SSO 回调: 授权码换 JWT")
@PostMapping("/{provider}/callback")
public R<SsoCallbackResponse> callback(@PathVariable String provider,
@RequestBody CallbackRequest body) {
if (body == null || body.code() == null || body.state() == null) {
return R.fail(400, "code 和 state 是必填项");
}
// handleCallback 返回结构化响应: bindRequired=false loginResponse 非空,
// bindRequired=true bindToken 非空 (link-only 模式)两种形态由前端判断
return R.ok(ssoService.handleCallback(provider, body.code(), body.state()));
}
@Operation(summary = "绑定 SSO 身份到已有账号 (link-only 模式)")
@PostMapping("/bind")
public R<LoginResponse> bind(@RequestBody BindRequest body) {
if (body == null || body.bindToken() == null || body.username() == null || body.password() == null) {
return R.fail(400, "bindToken, username, password 是必填项");
}
LoginResponse resp = ssoService.handleBind(body.bindToken(), body.username(), body.password());
return R.ok(resp);
}
public record CallbackRequest(String code, String state) {}
public record BindRequest(String bindToken, String username, String password) {}
}

View File

@ -0,0 +1,51 @@
package vip.mate.auth.sso;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* SSO 单点登录配置
* <p>
* 全局开关默认关闭, 不影响未启用 SSO 的现有部署
*
* @author MateClaw Team
*/
@Data
@ConfigurationProperties(prefix = "mateclaw.sso")
public class SsoProperties {
/** 是否启用 SSO (全局开关) */
private boolean enabled = false;
/**
* 仅允许绑定已有账号模式
* {@code false} = 未绑定时自动创建 mate_user; {@code true} = 要求绑定已存在的账号
*/
private boolean linkOnly = false;
/** 新建 SSO 用户的默认角色 */
private String defaultRole = "user";
/** 飞书 Provider 配置 */
private Feishu feishu = new Feishu();
@Data
public static class Feishu {
/** 是否启用飞书 SSO */
private boolean enabled = false;
/** 飞书应用 App ID */
private String appId;
/** 飞书应用 App Secret */
private String appSecret;
/**
* 国际版切换: {@code feishu} (国内) / {@code lark} (国际版 Lark)
* 决定 apiBase: {@code https://open.feishu.cn} / {@code https://open.larksuite.com}
*/
private String domain = "feishu";
/**
* SSO 回调地址, 通常 {@code https://your-domain/login?sso=callback}
* 飞书授权后带 code 回跳到此地址
*/
private String redirectUri;
}
}

View File

@ -0,0 +1,253 @@
package vip.mate.auth.sso;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import vip.mate.auth.model.LoginResponse;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.repository.UserMapper;
import vip.mate.auth.service.AuthService;
import vip.mate.auth.sso.model.ExternalIdentityEntity;
import vip.mate.auth.sso.provider.SsoProvider;
import vip.mate.auth.sso.provider.SsoProviderRegistry;
import vip.mate.auth.sso.provider.SsoUserInfo;
import vip.mate.auth.sso.repository.ExternalIdentityMapper;
import vip.mate.audit.service.AuditEventService;
import vip.mate.exception.MateClawException;
import java.time.LocalDateTime;
import java.util.Map;
/**
* SSO 核心业务逻辑: 授权 URL 构造回调用户映射账号绑定
* <p>
* 用户映射策略 (两者结合):
* <ul>
* <li>已绑定 更新 last_login + external 信息 签发 JWT</li>
* <li>未绑定 + link-only 签发 bind_token, 前端引导绑定</li>
* <li>未绑定 + 默认 自动创建 mate_user + external_identity</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SsoService {
private final SsoProviderRegistry registry;
private final SsoStateService stateService;
private final ExternalIdentityMapper identityMapper;
private final UserMapper userMapper;
private final AuthService authService;
private final SsoProperties ssoProperties;
private final BCryptPasswordEncoder passwordEncoder;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
/** Optional — audit may be null in narrow test contexts. */
@org.springframework.beans.factory.annotation.Autowired(required = false)
private AuditEventService auditService;
// ==================== authorize ====================
/**
* 构造授权 URL + 签发 state
*
* @return { authorizeUrl, state }
*/
public Map<String, String> handleAuthorize(String providerId) {
SsoProvider provider = registry.get(providerId)
.orElseThrow(() -> new MateClawException("err.sso.unknown_provider",
400, "未知的 SSO provider: " + providerId));
String state = stateService.issueState(providerId);
String url = provider.authorizeUrl(state);
return Map.of("authorizeUrl", url, "state", state);
}
// ==================== callback ====================
/**
* OAuth2 回调: code JWT
* <p>
* 已绑定用户直接签发 JWT; 未绑定用户根据 link-only 策略决定行为:
* <ul>
* <li>link-only 返回 {@link SsoCallbackResponse#bindRequired} 携带 bind_token</li>
* <li>默认 自动创建 mate_user (含并发幂等保护)</li>
* </ul>
*/
public SsoCallbackResponse handleCallback(String providerId, String code, String state) {
// 1. 校验 state (签名 + 过期 + 一次性消费)
stateService.verifyState(state);
// 2. code IdP 用户信息
SsoProvider provider = registry.get(providerId)
.orElseThrow(() -> new MateClawException("err.sso.unknown_provider",
400, "未知的 SSO provider: " + providerId));
SsoUserInfo info = provider.resolve(code, state);
// 3. 查已有绑定
ExternalIdentityEntity identity = findIdentity(providerId, info);
if (identity != null) {
UserEntity user = userMapper.selectById(identity.getUserId());
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) {
throw new MateClawException("err.sso.account_disabled",
403, "账号已停用或不存在");
}
updateIdentityOnLogin(identity, info);
audit("sso.login", providerId, info.externalId(), user.getId());
return SsoCallbackResponse.of(loginSuccess(user));
}
// 4. 未绑定
if (ssoProperties.isLinkOnly()) {
String bindToken = stateService.issueBindToken(providerId, info);
audit("sso.bind_required", providerId, info.externalId(), null);
return SsoCallbackResponse.bindRequired(bindToken, providerId, info.displayName());
}
// 5. 默认: 自动创建 (含并发幂等, 最多重试一次)
UserEntity newUser = createSsoUser(providerId, info, false);
return SsoCallbackResponse.of(loginSuccess(newUser));
}
// ==================== bind (link-only 模式) ====================
/**
* 绑定 SSO 身份到已有 mate_user 账号
* 校验 bind_token + 用户名密码 创建 external_identity 签发 JWT
*/
public LoginResponse handleBind(String bindToken, String username, String password) {
SsoStateService.BindTokenClaims claims = stateService.verifyBindToken(bindToken);
// 校验用户名密码 ( AuthService.login 一致的 BCrypt 校验)
UserEntity user = authService.findByUsername(username);
if (user == null || user.getPassword() == null
|| !passwordEncoder.matches(password, user.getPassword())) {
throw new MateClawException("err.auth.invalid_credentials",
401, "用户名或密码错误");
}
if (!Boolean.TRUE.equals(user.getEnabled())) {
throw new MateClawException("err.sso.account_disabled",
403, "账号已停用");
}
// 创建绑定 (并发幂等: UNIQUE(provider, external_id) 兜底)
try {
ExternalIdentityEntity identity = new ExternalIdentityEntity();
identity.setUserId(user.getId());
identity.setProvider(claims.provider());
identity.setExternalId(claims.externalId());
identity.setUnionId(claims.unionId());
identity.setExternalName(claims.externalName());
identity.setLastLoginAt(LocalDateTime.now());
identityMapper.insert(identity);
} catch (DuplicateKeyException e) {
throw new MateClawException("err.sso.already_bound",
409, "该飞书账号已绑定到其他用户");
}
audit("sso.bind", claims.provider(), claims.externalId(), user.getId());
return loginSuccess(user);
}
/**
* 查找已绑定的外部身份union_id 优先, 回退 external_id
*/
private ExternalIdentityEntity findIdentity(String providerId, SsoUserInfo info) {
// 优先 union_id
if (info.unionId() != null && !info.unionId().isBlank()) {
ExternalIdentityEntity byUnion = identityMapper.selectOne(
new LambdaQueryWrapper<ExternalIdentityEntity>()
.eq(ExternalIdentityEntity::getProvider, providerId)
.eq(ExternalIdentityEntity::getUnionId, info.unionId()));
if (byUnion != null) return byUnion;
}
// 回退 external_id
return identityMapper.selectOne(
new LambdaQueryWrapper<ExternalIdentityEntity>()
.eq(ExternalIdentityEntity::getProvider, providerId)
.eq(ExternalIdentityEntity::getExternalId, info.externalId()));
}
/**
* 更新绑定记录的 last_login + external 信息
*/
private void updateIdentityOnLogin(ExternalIdentityEntity identity, SsoUserInfo info) {
identityMapper.update(null, new LambdaUpdateWrapper<ExternalIdentityEntity>()
.eq(ExternalIdentityEntity::getId, identity.getId())
.set(ExternalIdentityEntity::getLastLoginAt, LocalDateTime.now())
.set(ExternalIdentityEntity::getExternalName, info.displayName())
.set(ExternalIdentityEntity::getExternalAvatar, info.avatarUrl())
.set(ExternalIdentityEntity::getExternalEmail, info.email()));
}
/**
* 自动创建 SSO 用户 (含并发幂等: catch DuplicateKeyException 回滚孤儿 user 重查)
*
* @param retry 是否已重试过一次第二次仍撞 PK 时直接抛异常 (不再递归, 避免栈溢出)
*/
private UserEntity createSsoUser(String providerId, SsoUserInfo info, boolean retry) {
UserEntity newUser = new UserEntity();
newUser.setUsername(providerId + "_" + info.externalId()); // feishu_<full open_id>
newUser.setPassword(null); // SSO 登录
newUser.setNickname(info.displayName());
newUser.setAvatar(info.avatarUrl());
newUser.setEmail(info.email());
newUser.setRole(ssoProperties.getDefaultRole());
newUser.setEnabled(true);
try {
userMapper.insert(newUser);
ExternalIdentityEntity identity = new ExternalIdentityEntity();
identity.setUserId(newUser.getId());
identity.setProvider(providerId);
identity.setExternalId(info.externalId());
identity.setUnionId(info.unionId());
identity.setExternalName(info.displayName());
identity.setExternalAvatar(info.avatarUrl());
identity.setExternalEmail(info.email());
identity.setLastLoginAt(LocalDateTime.now());
identityMapper.insert(identity);
audit("sso.auto_create", providerId, info.externalId(), newUser.getId());
return newUser;
} catch (DuplicateKeyException e) {
// 并发: 另一个请求已创建了该用户回滚刚建的孤儿 user, 重查已存在的 identity
log.info("[SSO] Concurrent auto-create for provider={}, externalId={}: "
+ "rolling back orphan user {}, falling back to existing", providerId, info.externalId(), newUser.getId());
userMapper.deleteById(newUser.getId()); // mate_user @TableLogic, 物理删
ExternalIdentityEntity existing = findIdentity(providerId, info);
if (existing != null) {
return userMapper.selectById(existing.getUserId());
}
// 极端竞态: identity 也被并发删了重试一次, 不再递归
if (retry) {
throw new MateClawException("err.sso.concurrent_create_failed",
503, "SSO 登录遇到并发冲突, 请重试");
}
return createSsoUser(providerId, info, true);
}
}
private LoginResponse loginSuccess(UserEntity user) {
String token = authService.generateToken(user);
return new LoginResponse(user.getId(), token, user.getUsername(),
user.getNickname(), user.getRole());
}
private void audit(String action, String provider, String externalId, Long userId) {
if (auditService != null) {
try {
String detail = objectMapper.writeValueAsString(java.util.Map.of(
"provider", provider != null ? provider : "",
"userId", userId != null ? userId : "null"));
auditService.record(action, "sso",
provider + ":" + externalId, externalId, detail);
} catch (Exception e) {
log.debug("[SSO] audit write failed for {}: {}", action, e.getMessage());
}
}
}
}

View File

@ -0,0 +1,228 @@
package vip.mate.auth.sso;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import vip.mate.auth.sso.model.SsoStateEntity;
import vip.mate.auth.sso.provider.SsoUserInfo;
import vip.mate.auth.sso.repository.SsoStateMapper;
import vip.mate.exception.MateClawException;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.UUID;
/**
* OAuth2 state / bind_token 签发与校验服务
* <p>
* DB (sso_state ) 而非内存: 多节点部署下 /authorize /callback 可能落到不同节点
* state bind_token jti 共用同一张表, {@code kind} 列区分
*
* <p><b>State</b> (OAuth2 CSRF):
* <ul>
* <li>签发: Base64(nonce + "." + HMAC-SHA256(nonce, jwtSecret)), DB (kind=state)</li>
* <li>校验: HMAC 签名 + 5min TTL + 一次性消费 (UPDATE consumed=1 WHERE consumed=0)</li>
* </ul>
*
* <p><b>bind_token</b> (link-only 模式, 自包含 JWT):
* <ul>
* <li>签发: JWT(jti, provider, externalId, ..., exp=10min), jwtSecret 签名</li>
* <li>校验: 验签 + 过期 + 单次消费 (jti 写入 sso_state PK, 只有首个请求成功)</li>
* </ul>
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SsoStateService {
private static final int STATE_TTL_SECONDS = 5 * 60; // 5 min
private static final int BIND_TOKEN_TTL_SECONDS = 10 * 60; // 10 min
private static final String KIND_STATE = "state";
private static final String KIND_BIND = "bind";
private final SsoStateMapper stateMapper;
@Value("${mateclaw.jwt.secret:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}")
private String jwtSecret;
// ==================== State (OAuth2 CSRF) ====================
/**
* 签发 OAuth2 state token 并持久化返回 Base64(nonce.signature) 格式
*/
public String issueState(String provider) {
String nonce = UUID.randomUUID().toString().replace("-", "");
String signature = hmacSha256Hex(nonce);
String state = nonce + "." + signature;
SsoStateEntity entity = new SsoStateEntity();
entity.setToken(state);
entity.setKind(KIND_STATE);
entity.setProvider(provider);
entity.setConsumed(0);
entity.setCreatedAt(LocalDateTime.now());
stateMapper.insert(entity);
return state;
}
/**
* 校验 state 签名 + 过期 + 一次性消费校验失败抛 400
*/
public void verifyState(String state) {
if (state == null || state.isBlank()) {
throw new MateClawException("err.sso.state_missing", 400, "缺少 state 参数");
}
int dot = state.indexOf('.');
if (dot <= 0 || dot >= state.length() - 1) {
throw new MateClawException("err.sso.state_invalid", 400, "state 格式无效");
}
String nonce = state.substring(0, dot);
String signature = state.substring(dot + 1);
// 1. HMAC 签名
String expected = hmacSha256Hex(nonce);
if (!expected.equals(signature)) {
throw new MateClawException("err.sso.state_invalid", 400, "state 签名校验失败");
}
// 2. 一次性消费 + TTL: UPDATE consumed=1 WHERE token=? AND consumed=0 AND created_at > cutoff.
// created_at 条件让 5min TTL 在消费阶段强制生效 否则未消费的 state
// 只在 1h purge 后才物理删除, /authorize 30min /callback 仍能通过
LocalDateTime cutoff = LocalDateTime.now().minusSeconds(STATE_TTL_SECONDS);
int rows = stateMapper.update(null, new LambdaUpdateWrapper<SsoStateEntity>()
.eq(SsoStateEntity::getToken, state)
.eq(SsoStateEntity::getConsumed, 0)
.gt(SsoStateEntity::getCreatedAt, cutoff)
.set(SsoStateEntity::getConsumed, 1));
if (rows == 0) {
throw new MateClawException("err.sso.state_expired_or_used",
400, "state 已过期或已被使用, 请重新登录");
}
}
// ==================== bind_token (link-only 模式) ====================
/**
* 签发 bind_token (自包含 JWT), 携带 IdP 用户信息TTL 10min
*/
public String issueBindToken(String provider, SsoUserInfo info) {
long now = System.currentTimeMillis();
return Jwts.builder()
.id(UUID.randomUUID().toString()) // jti
.claim("provider", provider)
.claim("externalId", info.externalId())
.claim("unionId", info.unionId())
.claim("externalName", info.displayName())
.issuedAt(new java.util.Date(now))
.expiration(new java.util.Date(now + BIND_TOKEN_TTL_SECONDS * 1000L))
.signWith(getSignKey())
.compact();
}
/**
* 校验 bind_token 验签 + 过期 + 单次消费 (jti PK)返回 claims 供绑定使用
*/
public BindTokenClaims verifyBindToken(String bindToken) {
if (bindToken == null || bindToken.isBlank()) {
throw new MateClawException("err.sso.bind_token_missing", 400, "缺少 bind_token");
}
// 1. 验签 + 过期
Claims claims;
try {
claims = Jwts.parser()
.verifyWith(getSignKey())
.build()
.parseSignedClaims(bindToken)
.getPayload();
} catch (Exception e) {
throw new MateClawException("err.sso.bind_token_invalid",
400, "bind_token 无效或已过期");
}
String jti = claims.getId();
if (jti == null) {
throw new MateClawException("err.sso.bind_token_invalid", 400, "bind_token 缺少 jti");
}
// 2. 单次消费: INSERT (token=jti, kind=bind) PK, 只有首个请求成功
SsoStateEntity consumed = new SsoStateEntity();
consumed.setToken(jti);
consumed.setKind(KIND_BIND);
consumed.setProvider(claims.get("provider", String.class));
consumed.setConsumed(1);
consumed.setCreatedAt(LocalDateTime.now());
try {
stateMapper.insert(consumed);
} catch (org.springframework.dao.DuplicateKeyException e) {
throw new MateClawException("err.sso.bind_token_used",
400, "bind_token 已被使用, 请重新登录");
}
return new BindTokenClaims(
claims.get("provider", String.class),
claims.get("externalId", String.class),
claims.get("unionId", String.class),
claims.get("externalName", String.class));
}
/** bind_token 校验通过后返回的 claims。 */
public record BindTokenClaims(String provider, String externalId,
String unionId, String externalName) {}
// ==================== 过期清理 (ShedLock 定时任务) ====================
/**
* 每小时清理过期 state/bind_token
* LambdaQuery + Java 时间过滤, 通吃三方言 (不用 NOW() - INTERVAL SQL 方言)
*/
@Scheduled(fixedDelay = 3600_000) // 1h
public void purgeExpired() {
LocalDateTime cutoff = LocalDateTime.now().minus(1, ChronoUnit.HOURS);
int deleted = stateMapper.delete(new LambdaQueryWrapper<SsoStateEntity>()
.lt(SsoStateEntity::getCreatedAt, cutoff));
if (deleted > 0) {
log.info("[SsoState] Purged {} expired state/bind rows (cutoff={})", deleted, cutoff);
}
}
// ==================== helpers ====================
private SecretKey getSignKey() {
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
// HMAC-SHA 需要 >= 256 bit (32 byte); secret 0x00 填充到 32 byte ( AuthService 一致)
if (keyBytes.length < 32) {
byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, keyBytes.length);
keyBytes = padded;
}
return Keys.hmacShaKeyFor(keyBytes);
}
private String hmacSha256Hex(String input) {
try {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(getSignKey());
byte[] hash = mac.doFinal(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException("HMAC-SHA256 failed", e);
}
}
}

View File

@ -0,0 +1,60 @@
package vip.mate.auth.sso.model;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户外部身份关联实体SSO
* <p>
* 一个 {@code mate_user} 可绑定多个 IdP 身份一个 {@code (provider, external_id)}
* 至多归属一个用户匹配优先级union_id跨应用唯一优先回退到 external_id
*
* @author MateClaw Team
*/
@Data
@TableName("mate_user_external_identity")
public class ExternalIdentityEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long userId;
/** 身份提供方标识: feishu / dingtalk / wecom / ... */
private String provider;
/** IdP 内用户标识, 通常是 open_id */
private String externalId;
/** 跨应用唯一标识 (飞书特有), nullable */
private String unionId;
private String externalName;
private String externalAvatar;
private String externalEmail;
private LocalDateTime lastLoginAt;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/**
* 逻辑删除 ( wiki 系列表 @TableLogic 约定一致)
* <p>
* 注意: {@code mate_user.deleted} 当前无 @TableLogic, 全局无 logic-delete-field,
* deleteById 是物理删 本表的逻辑删除独立于 mate_user解绑时 service
* 改写 external_id / union_id {@code <原值>_del_<timestamp>} 释放唯一约束
*/
@TableLogic
private Integer deleted;
}

View File

@ -0,0 +1,37 @@
package vip.mate.auth.sso.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* OAuth2 state / bind-token 防重放存储
* <p>
* DB 而非内存: 多节点部署下 /authorize /callback 可能落到不同节点,
* 内存存储会导致 state 找不到登录硬失败{@code kind} 区分 {@code state}
* (OAuth2 CSRF state) {@code bind} (bind_token jti)
*
* <p>一次性消费: state {@code UPDATE ... SET consumed=1 WHERE token=? AND consumed=0},
* affected rows 必须 = 1; bind_token jti {@code INSERT} PK 实现首个消费成功
*
* @author MateClaw Team
*/
@Data
@TableName("sso_state")
public class SsoStateEntity {
@TableId(type = IdType.INPUT)
private String token;
/** state | bind */
private String kind;
private String provider;
private Integer consumed;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,229 @@
package vip.mate.auth.sso.provider;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import vip.mate.auth.sso.SsoProperties;
import vip.mate.exception.MateClawException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
* 飞书 OAuth2 SSO Provider
* <p>
* 授权码流程:
* <ol>
* <li>app_id + app_secret app_access_token (有效期 2h, Caffeine 缓存 ~110min)</li>
* <li>app_access_token + code user_access_token (飞书 OIDC 端点)</li>
* <li>user_access_token 用户信息 (open_id / union_id / name / email / avatar)</li>
* </ol>
*
* <p>HTTP 调用模式复刻 {@code FeishuChannelAdapter.getUserName}:
* JDK {@code HttpClient} + Jackson {@code ObjectMapper} + 飞书 {@code code==0} 约定
* 注意 SSO app_access_token IM 渠道的 tenant_access_token 是不同 token不同应用, 无法复用
*
* <p>apiBase {@code domain} 切换: {@code feishu} {@code https://open.feishu.cn};
* {@code lark} {@code https://open.larksuite.com}
*
* @author MateClaw Team
*/
public class FeishuSsoProvider implements SsoProvider {
private static final String PROVIDER_ID = "feishu";
private static final String DISPLAY_NAME = "飞书";
private final SsoProperties.Feishu cfg;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String apiBase;
/** app_access_token 缓存: 飞书有效期 2h, TTL 110min 留余量 */
private final Cache<String, String> appTokenCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(110))
.maximumSize(1)
.build();
public FeishuSsoProvider(SsoProperties.Feishu cfg, ObjectMapper objectMapper) {
this.cfg = cfg;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
this.apiBase = "lark".equalsIgnoreCase(cfg.getDomain())
? "https://open.larksuite.com"
: "https://open.feishu.cn";
}
@Override
public String id() { return PROVIDER_ID; }
@Override
public String displayName() { return DISPLAY_NAME; }
@Override
public String authorizeUrl(String state) {
return apiBase + "/open-apis/authen/v1/authorize"
+ "?app_id=" + cfg.getAppId()
+ "&redirect_uri=" + encode(cfg.getRedirectUri())
+ "&response_type=code"
+ "&state=" + encode(state);
}
@Override
public SsoUserInfo resolve(String code, String state) {
String appAccessToken = getAppAccessToken();
String userAccessToken = exchangeUserAccessToken(code, appAccessToken);
return fetchUserInfo(userAccessToken);
}
// ------------------------------------------------------------------
// 飞书 API 调用
// ------------------------------------------------------------------
/**
* 获取 app_access_token (带缓存)POST /auth/v3/app_access_token/internal
*/
private String getAppAccessToken() {
String cached = appTokenCache.getIfPresent("token");
if (cached != null) return cached;
try {
String body = objectMapper.writeValueAsString(Map.of(
"app_id", cfg.getAppId(),
"app_secret", cfg.getAppSecret()));
Map<String, Object> resp = postJson(
apiBase + "/open-apis/auth/v3/app_access_token/internal", body, null);
checkCode(resp, "app_access_token");
String token = (String) resp.get("app_access_token");
if (token == null || token.isBlank()) {
throw new MateClawException("err.sso.feishu_token_empty",
502, "飞书未返回 app_access_token");
}
appTokenCache.put("token", token);
return token;
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.sso.feishu_app_token_failed",
502, "获取飞书 app_access_token 失败: " + e.getMessage());
}
}
/**
* code user_access_tokenPOST /authen/v1/oidc/access_token
*/
private String exchangeUserAccessToken(String code, String appAccessToken) {
try {
String body = objectMapper.writeValueAsString(Map.of(
"grant_type", "authorization_code",
"code", code));
Map<String, Object> resp = postJson(
apiBase + "/open-apis/authen/v1/oidc/access_token", body, appAccessToken);
checkCode(resp, "user_access_token");
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) resp.get("data");
if (data == null) {
throw new MateClawException("err.sso.feishu_no_data", 502, "飞书未返回 token 数据");
}
String token = (String) data.get("access_token");
if (token == null || token.isBlank()) {
throw new MateClawException("err.sso.feishu_user_token_empty",
502, "飞书未返回 user_access_token");
}
return token;
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.sso.feishu_code_exchange_failed",
502, "飞书授权码换取 token 失败: " + e.getMessage());
}
}
/**
* user_access_token 用户信息GET /authen/v1/user_info
*/
@SuppressWarnings("unchecked")
private SsoUserInfo fetchUserInfo(String userAccessToken) {
try {
Map<String, Object> resp = getJson(
apiBase + "/open-apis/authen/v1/user_info", userAccessToken);
checkCode(resp, "user_info");
Map<String, Object> data = (Map<String, Object>) resp.get("data");
if (data == null) {
throw new MateClawException("err.sso.feishu_no_user_data",
502, "飞书未返回用户信息");
}
String openId = str(data.get("open_id"));
if (openId == null || openId.isBlank()) {
throw new MateClawException("err.sso.feishu_no_open_id",
502, "飞书用户信息缺少 open_id");
}
return new SsoUserInfo(
openId,
str(data.get("union_id")),
str(data.get("name")),
str(data.get("avatar")),
str(data.get("email")),
str(data.get("mobile")));
} catch (MateClawException e) {
throw e;
} catch (Exception e) {
throw new MateClawException("err.sso.feishu_user_info_failed",
502, "获取飞书用户信息失败: " + e.getMessage());
}
}
// ------------------------------------------------------------------
// HTTP helpers (复刻 FeishuChannelAdapter.getUserName 模式)
// ------------------------------------------------------------------
private Map<String, Object> postJson(String url, String jsonBody, String bearerToken) throws Exception {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json; charset=utf-8")
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
if (bearerToken != null) {
builder.header("Authorization", "Bearer " + bearerToken);
}
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), Map.class);
}
private Map<String, Object> getJson(String url, String bearerToken) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + bearerToken)
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return objectMapper.readValue(response.body(), Map.class);
}
private void checkCode(Map<String, Object> resp, String api) {
Integer code = resp.get("code") instanceof Number n ? n.intValue() : null;
if (code == null || code != 0) {
String msg = str(resp.get("msg"));
throw new MateClawException("err.sso.feishu_api_error",
502, "飞书 " + api + " 接口返回错误: code=" + code + ", msg=" + msg);
}
}
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static String encode(String s) {
try {
return java.net.URLEncoder.encode(s, "UTF-8");
} catch (Exception e) {
return s;
}
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.auth.sso.provider;
/**
* SSO 身份提供方抽象每个 IdP飞书/钉钉/企微/...实现此接口
* <p>
* 注册到 {@link SsoProviderRegistry} 后由 {@code SsoController} id 路由
*
* @author MateClaw Team
*/
public interface SsoProvider {
/** Provider 标识, 如 "feishu" */
String id();
/** 展示名, 如 "飞书" (前端渲染按钮用) */
String displayName();
/**
* 构造授权 URL前端 window.location 跳转到此 URL 让用户授权
*
* @param state CSRF 防护 token, 原样附加到授权 URL state 参数
* @return 完整的 IdP 授权 URL
*/
String authorizeUrl(String state);
/**
* 用授权码换取用户信息
*
* @param code IdP 回调带回的授权码
* @param state 回调带回的 state已由 Controller 校验过签名 + 一次性消费
* @return IdP 侧的标准化用户身份信息
*/
SsoUserInfo resolve(String code, String state);
}

View File

@ -0,0 +1,48 @@
package vip.mate.auth.sso.provider;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* SSO Provider 注册表 id 查找 Provider, 列出已启用的 Provider 供前端渲染按钮
* <p>
* Provider 通过构造函数注入 (Spring {@code @ConditionalOnProperty} 按需实例化)
*
* @author MateClaw Team
*/
@Component
public class SsoProviderRegistry {
private final Map<String, SsoProvider> providers = new LinkedHashMap<>();
/**
* Spring 注入所有已启用的 {@link SsoProvider} bean SSO 未启用时该列表为空
*/
public SsoProviderRegistry(List<SsoProvider> providerBeans) {
if (providerBeans != null) {
for (SsoProvider p : providerBeans) {
providers.put(p.id(), p);
}
}
}
/** 按 id 查 */
public Optional<SsoProvider> get(String providerId) {
if (providerId == null) return Optional.empty();
return Optional.ofNullable(providers.get(providerId));
}
/** 列出所有已启用的 Provider (供前端渲染 SSO 按钮) */
public List<SsoProvider> listEnabled() {
return List.copyOf(providers.values());
}
/** 是否有任何 Provider 已启用 */
public boolean hasEnabled() {
return !providers.isEmpty();
}
}

View File

@ -0,0 +1,22 @@
package vip.mate.auth.sso.provider;
/**
* IdP 返回的标准化用户信息 Provider 把平台特异字段映射到此结构
*
* @param externalId open_idprovider 内唯一
* @param unionId union_id跨应用唯一, nullable 飞书需开启 union_id 数据权限
* @param displayName 昵称
* @param avatarUrl 头像 URL
* @param email 邮箱nullable
* @param mobile 手机nullable
*
* @author MateClaw Team
*/
public record SsoUserInfo(
String externalId,
String unionId,
String displayName,
String avatarUrl,
String email,
String mobile
) {}

View File

@ -0,0 +1,14 @@
package vip.mate.auth.sso.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.auth.sso.model.ExternalIdentityEntity;
/**
* 用户外部身份关联 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface ExternalIdentityMapper extends BaseMapper<ExternalIdentityEntity> {
}

View File

@ -0,0 +1,14 @@
package vip.mate.auth.sso.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.auth.sso.model.SsoStateEntity;
/**
* OAuth2 state / bind-token 存储 Mapper
*
* @author MateClaw Team
*/
@Mapper
public interface SsoStateMapper extends BaseMapper<SsoStateEntity> {
}

View File

@ -10,11 +10,12 @@ import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Rate limiter for login endpoint prevents brute force attacks.
* Allows max 5 login attempts per IP per minute.
* Rate limiter for login + SSO bind endpoints prevents brute force attacks.
* Allows max 5 attempts per IP per minute across all password-checking paths.
*
* @author MateClaw Team
*/
@ -23,7 +24,20 @@ import java.util.concurrent.atomic.AtomicInteger;
public class LoginRateLimitFilter implements Filter {
private static final int MAX_ATTEMPTS = 5;
private static final String LOGIN_PATH = "/api/v1/auth/login";
/**
* Endpoints that accept a username + password and must be rate-limited
* against brute force. SSO callback is excluded (no password submitted).
* <p>
* The counter is keyed by client IP only (not IP + path), so 5 failed
* password attempts on /auth/login also locks /auth/sso/bind for the same
* IP within the window. This is intentional: all entries share the same
* brute-force surface, and a normal user who fat-fingers their password
* 5 times is unlikely to immediately need SSO bind. If finer isolation is
* needed later, switch the cache key to {@code ip + ":" + path}.
*/
private static final Set<String> PROTECTED_PATHS = Set.of(
"/api/v1/auth/login",
"/api/v1/auth/sso/bind");
/** IP → attempt count, auto-expires after 1 minute */
private final Cache<String, AtomicInteger> attempts = Caffeine.newBuilder()
@ -36,7 +50,7 @@ public class LoginRateLimitFilter implements Filter {
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) request;
if ("POST".equalsIgnoreCase(httpReq.getMethod()) && LOGIN_PATH.equals(httpReq.getRequestURI())) {
if ("POST".equalsIgnoreCase(httpReq.getMethod()) && PROTECTED_PATHS.contains(httpReq.getRequestURI())) {
String ip = getClientIp(httpReq);
AtomicInteger count = attempts.get(ip, k -> new AtomicInteger(0));
int current = count.incrementAndGet();

View File

@ -83,6 +83,7 @@ public class SecurityConfig {
// 公开 API 接口
.requestMatchers(
"/api/v1/auth/login",
"/api/v1/auth/sso/**",
"/api/v1/agents/*/chat/stream",
"/api/v1/chat/stream",
"/api/v1/chat/*/stop",

View File

@ -153,6 +153,21 @@ mateclaw:
jwt:
secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}
expiration: 86400000
sso:
# 全局开关(默认关闭,不影响现有部署)
enabled: ${SSO_ENABLED:false}
# 「仅允许绑定已有账号」模式false = 允许自动创建新用户)
link-only: ${SSO_LINK_ONLY:false}
# 新建 SSO 用户的默认角色
default-role: ${SSO_DEFAULT_ROLE:user}
feishu:
enabled: ${SSO_FEISHU_ENABLED:false}
app-id: ${SSO_FEISHU_APP_ID:}
app-secret: ${SSO_FEISHU_APP_SECRET:}
# 国际版切换: feishu (国内) / lark (国际版 Lark)
domain: ${SSO_FEISHU_DOMAIN:feishu}
# SSO 回调地址,通常 https://your-domain/login?sso=callback
redirect-uri: ${SSO_FEISHU_REDIRECT_URI:}
# 搜索配置已迁移至数据库mate_system_setting 表),通过 UI 系统设置管理
# MCP server 配置已迁移至数据库mate_mcp_server 表),通过 UI 管理
mcp:

View File

@ -0,0 +1,79 @@
-- V159: SSO (single sign-on) infrastructure — external identity link table,
-- OAuth2 state store, and mate_user.password relaxation.
--
-- This migration introduces three changes for ISSUE #405 (飞书/SSO login):
--
-- 1. mate_user_external_identity — links a mate_user to one or more IdP
-- identities (feishu open_id / union_id, later dingtalk, wecom, ...).
-- A single user may bind multiple providers; a single (provider,
-- external_id) pair maps to at most one user.
--
-- Matching priority at login: union_id first (cross-app unique within a
-- Feishu tenant — requires the app to request the union_id data scope),
-- falling back to external_id (open_id, app-local). Deployments that do
-- not enable the union_id scope cannot de-duplicate across apps; the
-- deployment note must call this out.
--
-- Soft-delete (@TableLogic, matching wiki-package convention): unbinding
-- rewrites external_id/union_id to `<orig>_del_<epochMillis>` so the
-- UNIQUE constraints free up for a future re-bind, while the old row is
-- retained for audit. mate_user itself is NOT soft-delete in this repo
-- (V20 purged soft-delete on non-wiki tables), so the two tables have
-- independent delete semantics — only this table is @TableLogic.
--
-- 2. sso_state — persists OAuth2 'state' tokens and bind_token 'jti' values
-- so they are one-time-consumable across a multi-node deployment (in-mem
-- would lose state between /authorize on node A and /callback on node B).
-- The 'kind' column distinguishes 'state' (OAuth2 CSRF state) from 'bind'
-- (bind_token anti-replay jti). A ShedLock hourly job purges expired rows
-- (5-min state TTL + buffer) — the purge uses LambdaQuery + Java time so
-- it works on all three dialects without a NOW()-INTERVAL literal.
--
-- 3. ALTER mate_user.password — relax from NOT NULL to nullable so an
-- SSO-only user (never set a local password) can exist. AuthService.login
-- guards password IS NULL → reject password login (BCrypt null-hash is a
-- false match anyway, but an explicit check is clearer and audit-safe).
-- (1) external identity link ------------------------------------------------
CREATE TABLE IF NOT EXISTS mate_user_external_identity (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL, -- feishu / dingtalk / wecom / ...
external_id VARCHAR(128) NOT NULL, -- IdP-scoped id, usually open_id
union_id VARCHAR(128), -- cross-app unique (Feishu), nullable
external_name VARCHAR(128), -- IdP-side display name
external_avatar VARCHAR(512),
external_email VARCHAR(128),
last_login_at TIMESTAMP,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT NOT NULL DEFAULT 0
);
-- A single IdP identity maps to at most one active user.
CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_external
ON mate_user_external_identity (provider, external_id);
CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_union
ON mate_user_external_identity (provider, union_id);
-- List a user's bound identities.
CREATE INDEX IF NOT EXISTS idx_sso_user_provider
ON mate_user_external_identity (user_id, provider);
-- (2) OAuth2 state / bind-token store ---------------------------------------
CREATE TABLE IF NOT EXISTS sso_state (
token VARCHAR(128) PRIMARY KEY, -- state value or bind-token jti
kind VARCHAR(8) NOT NULL, -- 'state' | 'bind'
provider VARCHAR(32), -- provider id (null for 'bind' jti reuse)
consumed INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- (3) relax mate_user.password to nullable ----------------------------------
-- SSO-only users never set a local password; AuthService.login rejects
-- password=null before the BCrypt check.
ALTER TABLE mate_user ALTER COLUMN password DROP NOT NULL;

View File

@ -0,0 +1,37 @@
-- V159: SSO (single sign-on) infrastructure — external identity link table,
-- OAuth2 state store, and mate_user.password relaxation.
-- See the H2 file for the design rationale.
CREATE TABLE IF NOT EXISTS mate_user_external_identity (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
external_id VARCHAR(128) NOT NULL,
union_id VARCHAR(128),
external_name VARCHAR(128),
external_avatar VARCHAR(512),
external_email VARCHAR(128),
last_login_at TIMESTAMP(3),
create_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted SMALLINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_external
ON mate_user_external_identity (provider, external_id);
CREATE UNIQUE INDEX IF NOT EXISTS uk_sso_provider_union
ON mate_user_external_identity (provider, union_id);
CREATE INDEX IF NOT EXISTS idx_sso_user_provider
ON mate_user_external_identity (user_id, provider);
CREATE TABLE IF NOT EXISTS sso_state (
token VARCHAR(128) PRIMARY KEY,
kind VARCHAR(8) NOT NULL,
provider VARCHAR(32),
consumed SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE mate_user ALTER COLUMN password DROP NOT NULL;

View File

@ -0,0 +1,39 @@
-- V159: SSO (single sign-on) infrastructure — external identity link table,
-- OAuth2 state store, and mate_user.password relaxation.
-- See the H2 file for the design rationale.
CREATE TABLE IF NOT EXISTS mate_user_external_identity (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
external_id VARCHAR(128) NOT NULL,
union_id VARCHAR(128),
external_name VARCHAR(128),
external_avatar VARCHAR(512),
external_email VARCHAR(128),
last_login_at DATETIME(3),
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_sso_provider_external (provider, external_id),
UNIQUE KEY uk_sso_provider_union (provider, union_id),
KEY idx_sso_user_provider (user_id, provider)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
COMMENT = 'User external identity links for SSO (feishu/dingtalk/...).';
CREATE TABLE IF NOT EXISTS sso_state (
token VARCHAR(128) NOT NULL,
kind VARCHAR(8) NOT NULL,
provider VARCHAR(32),
consumed TINYINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (token)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
COMMENT = 'OAuth2 state + bind-token jti store (one-time consumable, multi-node).';
ALTER TABLE mate_user MODIFY COLUMN password VARCHAR(200) NULL;

View File

@ -0,0 +1,194 @@
package vip.mate.auth.sso;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.auth.sso.model.SsoStateEntity;
import vip.mate.auth.sso.provider.SsoUserInfo;
import vip.mate.auth.sso.repository.SsoStateMapper;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Verifies the SsoStateService state machine: state issue/verify/one-time-consume
* and bind_token issue/verify/anti-replay. These are the security-critical paths
* (CSRF + anti-replay) that the OAuth2 flow depends on.
*/
@ExtendWith(MockitoExtension.class)
class SsoStateServiceTest {
private static final String SECRET = "test-secret-0123456789-test-secret-01";
@Mock private SsoStateMapper stateMapper;
private SsoStateService service;
@BeforeAll
static void initMyBatisCache() {
// LambdaUpdateWrapper needs the entity's TableInfo in MyBatis-Plus's static cache.
// In a Spring context this happens during mapper scan; in a plain MockitoExtension
// test we trigger it manually.
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
SsoStateEntity.class);
}
@BeforeEach
void setUp() throws Exception {
service = new SsoStateService(stateMapper);
// jwtSecret is @Value-injected; set it via reflection since there's no Spring context.
Field f = SsoStateService.class.getDeclaredField("jwtSecret");
f.setAccessible(true);
f.set(service, SECRET);
}
// ---------------- state (OAuth2 CSRF) ----------------
@Test
@DisplayName("issueState persists a row and returns nonce.signature format")
void issueStatePersistsAndReturnsSignedFormat() {
String state = service.issueState("feishu");
assertThat(state).contains(".");
String[] parts = state.split("\\.", 2);
assertThat(parts[0]).isNotBlank(); // nonce
assertThat(parts[1]).hasSize(64); // HMAC-SHA256 hex
verify(stateMapper, times(1)).insert(any(SsoStateEntity.class));
ArgumentCaptor<SsoStateEntity> captor = ArgumentCaptor.forClass(SsoStateEntity.class);
verify(stateMapper).insert(captor.capture());
assertThat(captor.getValue().getToken()).isEqualTo(state);
assertThat(captor.getValue().getKind()).isEqualTo("state");
assertThat(captor.getValue().getConsumed()).isEqualTo(0);
}
@Test
@DisplayName("verifyState succeeds when UPDATE affects 1 row (first consumer)")
void verifyStateSucceedsOnFirstConsume() {
when(stateMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
String state = service.issueState("feishu");
service.verifyState(state); // should not throw
verify(stateMapper).update(isNull(), any(Wrapper.class));
}
@Test
@DisplayName("verifyState rejects replay when UPDATE affects 0 rows (already consumed)")
void verifyStateRejectsReplay() {
// Simulate: state already consumed by another request
when(stateMapper.update(isNull(), any(Wrapper.class))).thenReturn(0);
String state = service.issueState("feishu");
assertThatThrownBy(() -> service.verifyState(state))
.hasMessageContaining("已过期或已被使用");
}
@Test
@DisplayName("verifyState rejects tampered signature")
void verifyStateRejectsTamperedSignature() {
service.issueState("feishu");
// Tamper: valid nonce + wrong signature
String tampered = "abcdef0123456789." + "0".repeat(64);
assertThatThrownBy(() -> service.verifyState(tampered))
.hasMessageContaining("签名校验失败");
// No DB write attempted (fails at signature check before UPDATE)
verify(stateMapper, never()).update(any(), any());
}
@Test
@DisplayName("verifyState rejects malformed state (no dot)")
void verifyStateRejectsMalformed() {
assertThatThrownBy(() -> service.verifyState("nodothere"))
.hasMessageContaining("格式无效");
}
@Test
@DisplayName("verifyState rejects null/blank")
void verifyStateRejectsNull() {
assertThatThrownBy(() -> service.verifyState(null))
.hasMessageContaining("缺少 state");
}
// ---------------- bind_token (link-only anti-replay) ----------------
@Test
@DisplayName("issueBindToken returns a signed JWT with provider + externalId claims")
void issueBindTokenContainsClaims() {
SsoUserInfo info = new SsoUserInfo("ou_123", "on_union", "张三", null, null, null);
String token = service.issueBindToken("feishu", info);
assertThat(token).isNotBlank();
// JWT format: header.payload.signature (3 base64 parts separated by dots)
assertThat(token.split("\\.")).hasSize(3);
}
@Test
@DisplayName("verifyBindToken succeeds on first consume, inserts jti into sso_state")
void verifyBindTokenSucceedsOnFirstConsume() {
SsoUserInfo info = new SsoUserInfo("ou_456", null, "李四", null, null, null);
String token = service.issueBindToken("feishu", info);
SsoStateService.BindTokenClaims claims = service.verifyBindToken(token);
assertThat(claims.provider()).isEqualTo("feishu");
assertThat(claims.externalId()).isEqualTo("ou_456");
assertThat(claims.externalName()).isEqualTo("李四");
verify(stateMapper, times(1)).insert(any(SsoStateEntity.class));
ArgumentCaptor<SsoStateEntity> captor = ArgumentCaptor.forClass(SsoStateEntity.class);
verify(stateMapper).insert(captor.capture());
assertThat(captor.getValue().getKind()).isEqualTo("bind");
assertThat(captor.getValue().getConsumed()).isEqualTo(1);
}
@Test
@DisplayName("verifyBindToken rejects replay when jti already exists (DuplicateKeyException)")
void verifyBindTokenRejectsReplay() {
SsoUserInfo info = new SsoUserInfo("ou_789", null, "王五", null, null, null);
String token = service.issueBindToken("feishu", info);
// First insert succeeds; second insert (replay) throws DuplicateKeyException
when(stateMapper.insert(any(SsoStateEntity.class)))
.thenReturn(1)
.thenThrow(new org.springframework.dao.DuplicateKeyException("PK violation"));
// First consume: success
service.verifyBindToken(token);
// Second consume: rejected
assertThatThrownBy(() -> service.verifyBindToken(token))
.hasMessageContaining("已被使用");
}
@Test
@DisplayName("verifyBindToken rejects garbage token")
void verifyBindTokenRejectsGarbage() {
assertThatThrownBy(() -> service.verifyBindToken("not-a-jwt"))
.hasMessageContaining("无效或已过期");
}
@Test
@DisplayName("verifyBindToken rejects null/blank")
void verifyBindTokenRejectsNull() {
assertThatThrownBy(() -> service.verifyBindToken(null))
.hasMessageContaining("缺少 bind_token");
}
}

View File

@ -110,6 +110,20 @@ export const authApi = {
http.put(`/auth/users/${id}/password`, null, { params: { oldPassword, newPassword } }),
}
// ==================== SSO ====================
export const ssoApi = {
/** List enabled SSO providers (for rendering login buttons) */
providers: () => http.get('/auth/sso/providers'),
/** Get the authorize URL + state for a provider */
authorize: (provider: string) => http.get(`/auth/sso/${provider}/authorize`),
/** Exchange OAuth2 code for JWT */
callback: (provider: string, code: string, state: string) =>
http.post(`/auth/sso/${provider}/callback`, { code, state }),
/** Bind an SSO identity to an existing account (link-only mode) */
bind: (bindToken: string, username: string, password: string) =>
http.post('/auth/sso/bind', { bindToken, username, password }),
}
// ==================== Agent ====================
export const agentApi = {
/**

View File

@ -51,20 +51,53 @@
</button>
</form>
<!-- SSO providers (rendered only when the backend reports enabled providers) -->
<template v-if="ssoProviders.length > 0">
<div class="sso-divider">或使用</div>
<div class="sso-buttons">
<button
v-for="p in ssoProviders"
:key="p.id"
type="button"
class="sso-btn"
:disabled="loading"
@click="handleSsoLogin(p.id)"
>
{{ p.displayName }} 登录
</button>
</div>
</template>
<!-- SSO bind dialog (link-only mode: user must bind to an existing account) -->
<div v-if="bindDialog.visible" class="bind-dialog">
<div class="bind-dialog-content">
<h3 class="bind-title">首次使用 {{ bindDialog.provider }} 登录</h3>
<p class="bind-desc">请绑定你的 MateClaw 账号</p>
<input v-model="bindDialog.username" type="text" class="form-input" placeholder="MateClaw 用户名" autocomplete="username" />
<input v-model="bindDialog.password" type="password" class="form-input" placeholder="MateClaw 密码" autocomplete="current-password" />
<div v-if="bindDialog.error" class="error-msg">{{ bindDialog.error }}</div>
<button class="login-btn" :disabled="loading" @click="handleBind">绑定</button>
<button class="bind-cancel" @click="cancelBind">取消</button>
</div>
</div>
<p class="login-hint" v-html="t('login.hint')"></p>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { authApi } from '@/api/index'
import { authApi, ssoApi } from '@/api/index'
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
interface SsoProvider { id: string; displayName: string }
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const systemSettingsStore = useSystemSettingsStore()
@ -73,6 +106,49 @@ const showPassword = ref(false)
const errorMsg = ref('')
const form = reactive({ username: '', password: '' })
const ssoProviders = ref<SsoProvider[]>([])
const bindDialog = reactive({
visible: false,
bindToken: '',
provider: '',
username: '',
password: '',
error: '',
})
// Load enabled SSO providers on mount so the button only shows when configured.
onMounted(async () => {
try {
const res: any = await ssoApi.providers()
ssoProviders.value = (res.data || res) as SsoProvider[]
} catch {
// SSO not enabled or unreachable password login still works.
}
// Detect SSO callback: /login?sso=callback&code=xxx&provider=feishu
const query = route.query
if (query.sso === 'callback' && query.code && query.provider) {
await handleSsoCallback(String(query.provider), String(query.code), String(query.state || ''))
}
})
/** Shared login-success flow: write localStorage + navigate. */
async function applyLogin(data: { token: string; id: string | number; username: string; role: string }) {
localStorage.setItem('token', data.token)
localStorage.setItem('userId', String(data.id || '1'))
localStorage.setItem('username', data.username)
localStorage.setItem('role', data.role || 'user')
systemSettingsStore.load()
try {
await workspaceStore.fetchWorkspaces()
} catch {
/* default-deny is fine; router guard will still steer */
}
const target = workspaceStore.can('view:dashboard') ? '/dashboard' : '/chat'
router.push(target)
}
async function handleLogin() {
if (!form.username || !form.password) return
loading.value = true
@ -80,30 +156,82 @@ async function handleLogin() {
try {
const res: any = await authApi.login(form)
const data = res.data || res
localStorage.setItem('token', data.token)
localStorage.setItem('userId', String(data.id || '1'))
localStorage.setItem('username', data.username || form.username)
localStorage.setItem('role', data.role || 'user')
// Now authenticated load runtime settings (streamEnabled / debugMode) so
// the saved preferences take effect on the first turn. The app-boot load()
// runs before login and 401s, so without this the chat would fall back to
// defaults until the user opened the Settings page.
systemSettingsStore.load()
// Resolve capabilities before deciding the landing route so a viewer
// lands on /chat (their only capability) and member+ on /dashboard.
try {
await workspaceStore.fetchWorkspaces()
} catch {
/* default-deny is fine; router guard will still steer */
}
const target = workspaceStore.can('view:dashboard') ? '/dashboard' : '/chat'
router.push(target)
await applyLogin(data)
} catch (e: any) {
errorMsg.value = typeof e === 'string' ? e : t('login.failed')
} finally {
loading.value = false
}
}
/** Redirect to the IdP authorization page. */
async function handleSsoLogin(providerId: string) {
loading.value = true
errorMsg.value = ''
try {
const res: any = await ssoApi.authorize(providerId)
const data = res.data || res
if (data.authorizeUrl) {
window.location.href = data.authorizeUrl
}
} catch (e: any) {
errorMsg.value = typeof e === 'string' ? e : 'SSO 授权失败'
loading.value = false
}
}
/** Handle the OAuth2 callback (code → JWT). */
async function handleSsoCallback(provider: string, code: string, state: string) {
loading.value = true
errorMsg.value = ''
try {
const res: any = await ssoApi.callback(provider, code, state)
const data = res.data || res
// link-only mode: backend returns { bindRequired: true, bindToken, provider, displayName }
if (data.bindRequired) {
bindDialog.visible = true
bindDialog.bindToken = data.bindToken || ''
bindDialog.provider = data.provider || provider
bindDialog.error = ''
return
}
// Success: loginResponse carries the JWT
await applyLogin(data.loginResponse)
// Clean the query params so a refresh doesn't replay the callback.
router.replace({ path: '/login' })
} catch (e: any) {
errorMsg.value = typeof e === 'string' ? e : 'SSO 登录失败'
} finally {
loading.value = false
}
}
/** Submit the bind form (link-only mode). */
async function handleBind() {
if (!bindDialog.username || !bindDialog.password) return
loading.value = true
bindDialog.error = ''
try {
const res: any = await ssoApi.bind(bindDialog.bindToken, bindDialog.username, bindDialog.password)
const data = res.data || res
bindDialog.visible = false
await applyLogin(data)
} catch (e: any) {
bindDialog.error = typeof e === 'string' ? e : '绑定失败,请检查用户名和密码'
} finally {
loading.value = false
}
}
function cancelBind() {
bindDialog.visible = false
bindDialog.bindToken = ''
bindDialog.username = ''
bindDialog.password = ''
bindDialog.error = ''
}
</script>
<style scoped>
@ -252,6 +380,82 @@ html.dark .login-page {
cursor: not-allowed;
}
/* SSO buttons */
.sso-divider {
text-align: center;
font-size: 12px;
color: var(--mc-text-tertiary);
margin: 4px 0;
position: relative;
}
.sso-divider::before,
.sso-divider::after {
content: '';
display: inline-block;
width: 30%;
height: 1px;
background: var(--mc-border);
vertical-align: middle;
margin: 0 8px;
}
.sso-buttons {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.sso-btn {
width: 100%;
padding: 11px;
background: var(--mc-bg-elevated);
color: var(--mc-text-primary);
border: 1.5px solid var(--mc-border);
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
height: 44px;
}
.sso-btn:hover:not(:disabled) {
border-color: var(--mc-primary);
color: var(--mc-primary);
}
.sso-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Bind dialog */
.bind-dialog {
width: 100%;
}
.bind-dialog-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.bind-title {
font-size: 16px;
font-weight: 600;
color: var(--mc-text-primary);
margin: 0;
}
.bind-desc {
font-size: 13px;
color: var(--mc-text-tertiary);
margin: 0;
}
.bind-cancel {
width: 100%;
padding: 8px;
background: none;
color: var(--mc-text-tertiary);
border: none;
font-size: 13px;
cursor: pointer;
}
/* Loading */
.loading-dots {
display: flex;