mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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::getUsername, request.getUsername())
|
||||||
.eq(UserEntity::getEnabled, true));
|
.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, "用户名或密码错误");
|
throw new MateClawException("err.auth.invalid_credentials", 401, "用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,7 +213,10 @@ public class AuthService {
|
|||||||
return userMapper.selectById(userId);
|
return userMapper.selectById(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateToken(UserEntity user) {
|
/**
|
||||||
|
* 生成 JWT token。SSO 登录路径复用此方法签发格式一致的 token。
|
||||||
|
*/
|
||||||
|
public String generateToken(UserEntity user) {
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.subject(user.getUsername())
|
.subject(user.getUsername())
|
||||||
.claim("userId", user.getId())
|
.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.io.IOException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate limiter for login endpoint — prevents brute force attacks.
|
* Rate limiter for login + SSO bind endpoints — prevents brute force attacks.
|
||||||
* Allows max 5 login attempts per IP per minute.
|
* Allows max 5 attempts per IP per minute across all password-checking paths.
|
||||||
*
|
*
|
||||||
* @author MateClaw Team
|
* @author MateClaw Team
|
||||||
*/
|
*/
|
||||||
@ -23,7 +24,20 @@ import java.util.concurrent.atomic.AtomicInteger;
|
|||||||
public class LoginRateLimitFilter implements Filter {
|
public class LoginRateLimitFilter implements Filter {
|
||||||
|
|
||||||
private static final int MAX_ATTEMPTS = 5;
|
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 */
|
/** IP → attempt count, auto-expires after 1 minute */
|
||||||
private final Cache<String, AtomicInteger> attempts = Caffeine.newBuilder()
|
private final Cache<String, AtomicInteger> attempts = Caffeine.newBuilder()
|
||||||
@ -36,7 +50,7 @@ public class LoginRateLimitFilter implements Filter {
|
|||||||
throws IOException, ServletException {
|
throws IOException, ServletException {
|
||||||
HttpServletRequest httpReq = (HttpServletRequest) request;
|
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);
|
String ip = getClientIp(httpReq);
|
||||||
AtomicInteger count = attempts.get(ip, k -> new AtomicInteger(0));
|
AtomicInteger count = attempts.get(ip, k -> new AtomicInteger(0));
|
||||||
int current = count.incrementAndGet();
|
int current = count.incrementAndGet();
|
||||||
|
|||||||
@ -83,6 +83,7 @@ public class SecurityConfig {
|
|||||||
// 公开 API 接口
|
// 公开 API 接口
|
||||||
.requestMatchers(
|
.requestMatchers(
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
|
"/api/v1/auth/sso/**",
|
||||||
"/api/v1/agents/*/chat/stream",
|
"/api/v1/agents/*/chat/stream",
|
||||||
"/api/v1/chat/stream",
|
"/api/v1/chat/stream",
|
||||||
"/api/v1/chat/*/stop",
|
"/api/v1/chat/*/stop",
|
||||||
|
|||||||
@ -153,6 +153,21 @@ mateclaw:
|
|||||||
jwt:
|
jwt:
|
||||||
secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}
|
secret: ${JWT_SECRET:MateClaw-JWT-Secret-Key-2024-Please-Change-In-Production}
|
||||||
expiration: 86400000
|
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 系统设置管理
|
# 搜索配置已迁移至数据库(mate_system_setting 表),通过 UI 系统设置管理
|
||||||
# MCP server 配置已迁移至数据库(mate_mcp_server 表),通过 UI 管理
|
# MCP server 配置已迁移至数据库(mate_mcp_server 表),通过 UI 管理
|
||||||
mcp:
|
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 } }),
|
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 ====================
|
// ==================== Agent ====================
|
||||||
export const agentApi = {
|
export const agentApi = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -51,20 +51,53 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
<p class="login-hint" v-html="t('login.hint')"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { reactive, ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { authApi } from '@/api/index'
|
import { authApi, ssoApi } from '@/api/index'
|
||||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||||
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
import { useSystemSettingsStore } from '@/stores/useSystemSettingsStore'
|
||||||
|
|
||||||
|
interface SsoProvider { id: string; displayName: string }
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const systemSettingsStore = useSystemSettingsStore()
|
const systemSettingsStore = useSystemSettingsStore()
|
||||||
@ -73,6 +106,49 @@ const showPassword = ref(false)
|
|||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const form = reactive({ username: '', password: '' })
|
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() {
|
async function handleLogin() {
|
||||||
if (!form.username || !form.password) return
|
if (!form.username || !form.password) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@ -80,30 +156,82 @@ async function handleLogin() {
|
|||||||
try {
|
try {
|
||||||
const res: any = await authApi.login(form)
|
const res: any = await authApi.login(form)
|
||||||
const data = res.data || res
|
const data = res.data || res
|
||||||
localStorage.setItem('token', data.token)
|
await applyLogin(data)
|
||||||
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)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
errorMsg.value = typeof e === 'string' ? e : t('login.failed')
|
errorMsg.value = typeof e === 'string' ? e : t('login.failed')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -252,6 +380,82 @@ html.dark .login-page {
|
|||||||
cursor: not-allowed;
|
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 */
|
||||||
.loading-dots {
|
.loading-dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user