mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
* 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:
parent
e79fb00fec
commit
03a6d61131
@ -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 token。SSO 登录路径复用此方法签发格式一致的 token。
|
||||
*/
|
||||
public String generateToken(UserEntity user) {
|
||||
return Jwts.builder()
|
||||
.subject(user.getUsername())
|
||||
.claim("userId", user.getId())
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
253
mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java
Normal file
253
mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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_token。POST /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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package vip.mate.auth.sso.provider;
|
||||
|
||||
/**
|
||||
* IdP 返回的标准化用户信息。各 Provider 把平台特异字段映射到此结构。
|
||||
*
|
||||
* @param externalId open_id(provider 内唯一)
|
||||
* @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
|
||||
) {}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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 = {
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user