diff --git a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java index 07ccad95..1bf490e3 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/service/AuthService.java @@ -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()) diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java new file mode 100644 index 00000000..e361201a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoAutoConfiguration.java @@ -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。 + *

+ * 仅当 {@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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java new file mode 100644 index 00000000..ca81a91a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoCallbackResponse.java @@ -0,0 +1,43 @@ +package vip.mate.auth.sso; + +import lombok.AllArgsConstructor; +import lombok.Data; +import vip.mate.auth.model.LoginResponse; + +/** + * SSO 回调响应。两种互斥形态由 {@code bindRequired} 区分: + *

+ * + *

替代了原先用 {@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); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java new file mode 100644 index 00000000..ee4bc3cd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoController.java @@ -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>> providers() { + List> 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> authorize(@PathVariable String provider) { + return R.ok(ssoService.handleAuthorize(provider)); + } + + @Operation(summary = "SSO 回调: 授权码换 JWT") + @PostMapping("/{provider}/callback") + public R 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 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) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java new file mode 100644 index 00000000..9c2c86e3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoProperties.java @@ -0,0 +1,51 @@ +package vip.mate.auth.sso; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * SSO 单点登录配置。 + *

+ * 全局开关默认关闭, 不影响未启用 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java new file mode 100644 index 00000000..d127570e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoService.java @@ -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 构造、回调用户映射、账号绑定。 + *

+ * 用户映射策略 (两者结合): + *

+ * + * @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 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。 + *

+ * 已绑定用户直接签发 JWT; 未绑定用户根据 link-only 策略决定行为: + *

+ */ + 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() + .eq(ExternalIdentityEntity::getProvider, providerId) + .eq(ExternalIdentityEntity::getUnionId, info.unionId())); + if (byUnion != null) return byUnion; + } + // 回退 external_id + return identityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ExternalIdentityEntity::getProvider, providerId) + .eq(ExternalIdentityEntity::getExternalId, info.externalId())); + } + + /** + * 更新绑定记录的 last_login + external 信息。 + */ + private void updateIdentityOnLogin(ExternalIdentityEntity identity, SsoUserInfo info) { + identityMapper.update(null, new LambdaUpdateWrapper() + .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_ + 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()); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java new file mode 100644 index 00000000..27139fa7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/SsoStateService.java @@ -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 签发与校验服务。 + *

+ * 落 DB (sso_state 表) 而非内存: 多节点部署下 /authorize 与 /callback 可能落到不同节点。 + * state 和 bind_token 的 jti 共用同一张表, 用 {@code kind} 列区分。 + * + *

State (OAuth2 CSRF): + *

    + *
  • 签发: Base64(nonce + "." + HMAC-SHA256(nonce, jwtSecret)), 存 DB (kind=state)
  • + *
  • 校验: 验 HMAC 签名 + 5min TTL + 一次性消费 (UPDATE consumed=1 WHERE consumed=0)
  • + *
+ * + *

bind_token (link-only 模式, 自包含 JWT): + *

    + *
  • 签发: JWT(jti, provider, externalId, ..., exp=10min), 用 jwtSecret 签名
  • + *
  • 校验: 验签 + 过期 + 单次消费 (jti 写入 sso_state 撞 PK, 只有首个请求成功)
  • + *
+ * + * @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() + .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() + .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); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java new file mode 100644 index 00000000..97f54142 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/ExternalIdentityEntity.java @@ -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)。 + *

+ * 一个 {@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 约定一致)。 + *

+ * 注意: {@code mate_user.deleted} 当前无 @TableLogic, 全局无 logic-delete-field, + * 其 deleteById 是物理删 —— 本表的逻辑删除独立于 mate_user。解绑时 service 层 + * 改写 external_id / union_id 为 {@code <原值>_del_} 释放唯一约束。 + */ + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java new file mode 100644 index 00000000..35d44428 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/model/SsoStateEntity.java @@ -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 防重放存储。 + *

+ * 落 DB 而非内存: 多节点部署下 /authorize 与 /callback 可能落到不同节点, + * 内存存储会导致 state 找不到、登录硬失败。{@code kind} 区分 {@code state} + * (OAuth2 CSRF state) 与 {@code bind} (bind_token 的 jti)。 + * + *

一次性消费: 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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java new file mode 100644 index 00000000..9f123043 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/FeishuSsoProvider.java @@ -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。 + *

+ * 授权码流程: + *

    + *
  1. app_id + app_secret → app_access_token (有效期 2h, Caffeine 缓存 ~110min)
  2. + *
  3. app_access_token + code → user_access_token (飞书 OIDC 端点)
  4. + *
  5. user_access_token → 用户信息 (open_id / union_id / name / email / avatar)
  6. + *
+ * + *

HTTP 调用模式复刻 {@code FeishuChannelAdapter.getUserName}: + * JDK {@code HttpClient} + Jackson {@code ObjectMapper} + 飞书 {@code code==0} 约定。 + * 注意 SSO 的 app_access_token 与 IM 渠道的 tenant_access_token 是不同 token、不同应用, 无法复用。 + * + *

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 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 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 resp = postJson( + apiBase + "/open-apis/authen/v1/oidc/access_token", body, appAccessToken); + checkCode(resp, "user_access_token"); + @SuppressWarnings("unchecked") + Map data = (Map) 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 resp = getJson( + apiBase + "/open-apis/authen/v1/user_info", userAccessToken); + checkCode(resp, "user_info"); + Map data = (Map) 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 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 response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return objectMapper.readValue(response.body(), Map.class); + } + + private Map 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 response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return objectMapper.readValue(response.body(), Map.class); + } + + private void checkCode(Map 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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java new file mode 100644 index 00000000..7e314f70 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProvider.java @@ -0,0 +1,34 @@ +package vip.mate.auth.sso.provider; + +/** + * SSO 身份提供方抽象。每个 IdP(飞书/钉钉/企微/...)实现此接口。 + *

+ * 注册到 {@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); +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java new file mode 100644 index 00000000..ca8b0887 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoProviderRegistry.java @@ -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 供前端渲染按钮。 + *

+ * Provider 通过构造函数注入 (Spring 按 {@code @ConditionalOnProperty} 按需实例化)。 + * + * @author MateClaw Team + */ +@Component +public class SsoProviderRegistry { + + private final Map providers = new LinkedHashMap<>(); + + /** + * Spring 注入所有已启用的 {@link SsoProvider} bean。当 SSO 未启用时该列表为空。 + */ + public SsoProviderRegistry(List providerBeans) { + if (providerBeans != null) { + for (SsoProvider p : providerBeans) { + providers.put(p.id(), p); + } + } + } + + /** 按 id 查 */ + public Optional get(String providerId) { + if (providerId == null) return Optional.empty(); + return Optional.ofNullable(providers.get(providerId)); + } + + /** 列出所有已启用的 Provider (供前端渲染 SSO 按钮) */ + public List listEnabled() { + return List.copyOf(providers.values()); + } + + /** 是否有任何 Provider 已启用 */ + public boolean hasEnabled() { + return !providers.isEmpty(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java new file mode 100644 index 00000000..5962931f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/provider/SsoUserInfo.java @@ -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 +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java new file mode 100644 index 00000000..70dc564e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/ExternalIdentityMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java new file mode 100644 index 00000000..8e6c62a3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/sso/repository/SsoStateMapper.java @@ -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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java b/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java index a56a32ce..6352c9de 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/config/LoginRateLimitFilter.java @@ -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). + *

+ * 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 PROTECTED_PATHS = Set.of( + "/api/v1/auth/login", + "/api/v1/auth/sso/bind"); /** IP → attempt count, auto-expires after 1 minute */ private final Cache 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(); diff --git a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java index f4cd853c..9d71da61 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/SecurityConfig.java @@ -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", diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index f3b074b1..8cf397ea 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -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: diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql b/mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql new file mode 100644 index 00000000..55b0d77b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V159__sso_external_identity.sql @@ -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 `_del_` 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; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V159__sso_external_identity.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V159__sso_external_identity.sql new file mode 100644 index 00000000..2dfa8336 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V159__sso_external_identity.sql @@ -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; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V159__sso_external_identity.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V159__sso_external_identity.sql new file mode 100644 index 00000000..71904d5e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V159__sso_external_identity.sql @@ -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; diff --git a/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java b/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java new file mode 100644 index 00000000..b98c57e2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/auth/sso/SsoStateServiceTest.java @@ -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 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 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"); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index b6291684..05f1f592 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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 = { /** diff --git a/mateclaw-ui/src/views/Login.vue b/mateclaw-ui/src/views/Login.vue index 17515cea..6a32f596 100644 --- a/mateclaw-ui/src/views/Login.vue +++ b/mateclaw-ui/src/views/Login.vue @@ -51,20 +51,53 @@ + + + + +

+
+

首次使用 {{ bindDialog.provider }} 登录

+

请绑定你的 MateClaw 账号

+ + +
{{ bindDialog.error }}
+ + +
+
+