M3: Casdoor 账号账权全量接管
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (java-kotlin) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
CodeQL Advanced / Analyze (python) (push) Canceled after 0s
Docker Image Build & Push / manifest (push) Canceled after 0s
Docker Image Build & Push / build-and-push-arm (push) Canceled after 0s
Docker Image Build & Push / build-and-push-amd (push) Canceled after 0s
Docker Image Build & Push / manifest (push) Canceled after 0s
Docker Image Build & Push / build-and-push-arm (push) Canceled after 0s
Docker Image Build & Push / build-and-push-amd (push) Canceled after 0s
Frontend Docker Image CI / Build and Push Frontend Docker Image (push) Canceled after 0s
Gateway Docker Image CI / Build and Push Gateway Docker Image (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (java-kotlin) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
CodeQL Advanced / Analyze (python) (push) Canceled after 0s
Docker Image Build & Push / manifest (push) Canceled after 0s
Docker Image Build & Push / build-and-push-arm (push) Canceled after 0s
Docker Image Build & Push / build-and-push-amd (push) Canceled after 0s
Docker Image Build & Push / manifest (push) Canceled after 0s
Docker Image Build & Push / build-and-push-arm (push) Canceled after 0s
Docker Image Build & Push / build-and-push-amd (push) Canceled after 0s
Frontend Docker Image CI / Build and Push Frontend Docker Image (push) Canceled after 0s
Gateway Docker Image CI / Build and Push Gateway Docker Image (push) Canceled after 0s
- accountOnly 时本地登录/注册 403,前端隐藏入口 - validateToken 增加用户存在+启用校验,禁用账号存量 JWT 立即失效 - 新增 CasdoorAdminClient(password grant 管理令牌)+ 全量账号同步服务(5 分钟定时+手动端点) - /sso/casdoor/config 暴露 accountOnly; POST /sso/casdoor/sync 手动同步(admin)
This commit is contained in:
+9
-1
@@ -82,10 +82,18 @@ public class UserService {
|
||||
return builder.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验本地 JWT。M3 起额外校验用户状态:用户不存在或已禁用(本地/同步置禁)
|
||||
* 返回 null → 网关 AuthFilter 直接 401,禁用账号的存量 token 立即失效。
|
||||
*/
|
||||
public String validateToken(String token) {
|
||||
try {
|
||||
Jws<Claims> claimsJws = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))).build().parseSignedClaims(token);
|
||||
return claimsJws.getPayload().getSubject();
|
||||
String username = claimsJws.getPayload().getSubject();
|
||||
return findUserByUsername(username)
|
||||
.filter(User::isEnabled)
|
||||
.map(User::getUsername)
|
||||
.orElse(null);
|
||||
} catch (JwtException | IllegalArgumentException ex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import com.deepdata.gateway.common.config.RoleResolver;
|
||||
import com.deepdata.gateway.domain.entity.User;
|
||||
import com.deepdata.gateway.domain.repository.UserRepository;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.dto.CasdoorUserDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.SchedulingConfigurer;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Casdoor 账号全量同步(M3:Casdoor 为账号唯一数据源)。
|
||||
*
|
||||
* <p>语义(每次运行):
|
||||
* <ul>
|
||||
* <li>Casdoor 有、本地无 → 建号(占位密码,登录只能走 SSO);</li>
|
||||
* <li>两边都有 → 刷新邮箱 / user_groups / role(口径与登录 upsert 一致);
|
||||
* Casdoor 禁用 → 本地禁用(存量本地 JWT 立即被 {@code validateToken} 拒绝);
|
||||
* Casdoor 恢复启用 → 本地重新启用;</li>
|
||||
* <li>本地有、Casdoor 无 → 本地禁用(system 账号除外)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>触发:定时({@code sync-interval-minutes},默认 5 分钟)+ 管理端手动
|
||||
* (POST /sso/casdoor/sync)。仅 {@code enabled && accountOnly} 时启用。
|
||||
*
|
||||
* @since 2026/8/31
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CasdoorAccountSyncService {
|
||||
|
||||
private static final String SYSTEM_USER = "system";
|
||||
|
||||
private final CasdoorAdminClient adminClient;
|
||||
private final CasdoorProperties props;
|
||||
private final UserRepository userRepository;
|
||||
private final RoleResolver roleResolver;
|
||||
|
||||
/** 防止定时/手动同步并发重叠 */
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* 执行一次全量同步。
|
||||
*
|
||||
* @return 摘要 {total, created, updated, disabled, reenabled, skipped}
|
||||
*/
|
||||
public Mono<Map<String, Object>> syncFull() {
|
||||
return adminClient.listUsers()
|
||||
.flatMap(remotes -> Mono.fromCallable(() -> applySync(remotes))
|
||||
.subscribeOn(Schedulers.boundedElastic()));
|
||||
}
|
||||
|
||||
private Map<String, Object> applySync(List<CasdoorUserDto> remotes) {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
Map<String, Object> skip = new LinkedHashMap<>();
|
||||
skip.put("skipped", "already running");
|
||||
return skip;
|
||||
}
|
||||
try {
|
||||
Map<String, User> locals = new HashMap<>();
|
||||
for (User u : userRepository.list()) {
|
||||
if (u.getUsername() != null) {
|
||||
locals.put(u.getUsername(), u);
|
||||
}
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
Set<String> remoteNames = new HashSet<>();
|
||||
int created = 0;
|
||||
int updated = 0;
|
||||
int disabled = 0;
|
||||
int reenabled = 0;
|
||||
|
||||
for (CasdoorUserDto r : remotes) {
|
||||
String username = CasdoorUserService.normalizeUsername(r.getName());
|
||||
if (username == null || SYSTEM_USER.equals(username)) {
|
||||
continue;
|
||||
}
|
||||
remoteNames.add(username);
|
||||
User user = locals.get(username);
|
||||
if (user == null) {
|
||||
user = createUser(r, username);
|
||||
userRepository.save(user);
|
||||
created++;
|
||||
log.info("casdoor sync: created local user [{}] role={} groups={}",
|
||||
username, user.getRole(), user.getUserGroups());
|
||||
continue;
|
||||
}
|
||||
boolean touched = false;
|
||||
if (r.getEmail() != null && !r.getEmail().isBlank() && !r.getEmail().equals(user.getEmail())) {
|
||||
user.setEmail(r.getEmail());
|
||||
touched = true;
|
||||
}
|
||||
if (r.getGroups() != null && !r.getGroups().equals(user.getUserGroups())) {
|
||||
user.setUserGroups(r.getGroups());
|
||||
touched = true;
|
||||
}
|
||||
String role = roleResolver.resolveRole(username, r.getGroups());
|
||||
if (role != null && !role.equals(user.getRole())) {
|
||||
user.setRole(role);
|
||||
touched = true;
|
||||
}
|
||||
boolean casdoorDisabled = r.isDisabledOrForbidden();
|
||||
if (casdoorDisabled && user.isEnabled()) {
|
||||
user.setEnabled(false);
|
||||
disabled++;
|
||||
touched = true;
|
||||
log.warn("casdoor sync: disabled local user [{}] (forbidden/disabled in casdoor)", username);
|
||||
} else if (!casdoorDisabled && !user.isEnabled()) {
|
||||
user.setEnabled(true);
|
||||
reenabled++;
|
||||
touched = true;
|
||||
log.info("casdoor sync: re-enabled local user [{}]", username);
|
||||
}
|
||||
if (touched) {
|
||||
userRepository.updateById(user);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
// 本地有、Casdoor 无 → 禁用
|
||||
for (Map.Entry<String, User> e : locals.entrySet()) {
|
||||
if (SYSTEM_USER.equals(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (!remoteNames.contains(e.getKey()) && e.getValue().isEnabled()) {
|
||||
e.getValue().setEnabled(false);
|
||||
userRepository.updateById(e.getValue());
|
||||
disabled++;
|
||||
log.warn("casdoor sync: disabled local user [{}] (absent in casdoor)", e.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("total", remotes.size());
|
||||
summary.put("created", created);
|
||||
summary.put("updated", updated);
|
||||
summary.put("disabled", disabled);
|
||||
summary.put("reenabled", reenabled);
|
||||
log.info("casdoor sync done: {}", summary);
|
||||
return summary;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 定时触发(仅 accountOnly 生效) */
|
||||
void scheduledSync() {
|
||||
if (!props.isEnabled() || !props.isAccountOnly()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
syncFull()
|
||||
.doOnError(e -> log.error("casdoor scheduled sync failed: {}", e.getMessage()))
|
||||
.onErrorResume(e -> Mono.just(Map.of("error", String.valueOf(e.getMessage()))))
|
||||
.subscribe();
|
||||
} catch (Exception e) {
|
||||
log.error("casdoor scheduled sync rejected: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private User createUser(CasdoorUserDto r, String username) {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setEmail(r.getEmail());
|
||||
user.setFullName(r.getName());
|
||||
user.setPasswordHash(new BCryptPasswordEncoder(10).encode(UUID.randomUUID().toString()));
|
||||
user.setEnabled(!r.isDisabledOrForbidden());
|
||||
user.setUserGroups(r.getGroups());
|
||||
user.setRole(roleResolver.resolveRole(username, r.getGroups()));
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 定时任务注册:间隔取 syncIntervalMinutes(避免 fixedDelayString 无法读 @ConfigurationProperties) */
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
static class SyncScheduling implements SchedulingConfigurer {
|
||||
private final CasdoorAccountSyncService service;
|
||||
private final CasdoorProperties props;
|
||||
|
||||
SyncScheduling(CasdoorAccountSyncService service, CasdoorProperties props) {
|
||||
this.service = service;
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureTasks(ScheduledTaskRegistrar registrar) {
|
||||
long intervalMillis = Math.max(1, props.getSyncIntervalMinutes()) * 60_000L;
|
||||
registrar.addFixedDelayTask(service::scheduledSync, intervalMillis);
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import com.deepdata.gateway.infrastructure.casdoor.dto.CasdoorUserDto;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Casdoor 管理 API 客户端(M3:账号全量同步)。
|
||||
*
|
||||
* <p>鉴权两档:
|
||||
* <ul>
|
||||
* <li>{@code api-token} 配置了 → 直接 Bearer(新版 Casdoor API Tokens);</li>
|
||||
* <li>否则用 {@code sync-username}/{@code sync-password} 走 deepdata 应用的
|
||||
* password grant 换短期 access_token(本 31.23 实例为老版,无 API Tokens 功能),
|
||||
* 并按 expires_in 缓存复用,过期前 60s 刷新。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>用户列表端点:老版实例无 {@code /api/get-accounts},实测用
|
||||
* {@code /api/get-users?owner=...}(2026-08-31 验证)。
|
||||
*
|
||||
* @since 2026/8/31
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CasdoorAdminClient {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final String TOKEN_PATH = "/api/login/oauth/access_token";
|
||||
|
||||
private final CasdoorProperties props;
|
||||
private final CasdoorClient casdoorClient;
|
||||
private final WebClient webClient;
|
||||
private final AtomicReference<CachedToken> tokenCache = new AtomicReference<>();
|
||||
|
||||
public CasdoorAdminClient(CasdoorProperties props, CasdoorClient casdoorClient) {
|
||||
this.props = props;
|
||||
this.casdoorClient = casdoorClient;
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, props.getConnectTimeoutMillis())
|
||||
.responseTimeout(Duration.ofMillis(props.getReadTimeoutMillis()));
|
||||
this.webClient = WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取组织全量用户(含组与禁用标志)。
|
||||
*
|
||||
* @return 用户列表;Casdoor 异常时以 error Mono 暴露
|
||||
*/
|
||||
public Mono<List<CasdoorUserDto>> listUsers() {
|
||||
return adminToken().flatMap(token -> webClient.get()
|
||||
.uri(apiBase() + "/api/get-users?owner=" + enc(props.getOrgOwner()))
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.map(this::parseUsers)
|
||||
.doOnNext(users -> log.info("casdoor sync: fetched {} users from org [{}]",
|
||||
users.size(), props.getOrgOwner())));
|
||||
}
|
||||
|
||||
/** 取管理令牌:apiToken 直用;否则 password grant + 缓存 */
|
||||
private Mono<String> adminToken() {
|
||||
if (hasText(props.getApiToken())) {
|
||||
return Mono.just(props.getApiToken());
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
CachedToken cached = tokenCache.get();
|
||||
if (cached != null && cached.expiresAtMillis() - 60_000L > now) {
|
||||
return Mono.just(cached.token());
|
||||
}
|
||||
if (!hasText(props.getSyncUsername()) || !hasText(props.getSyncPassword())) {
|
||||
return Mono.error(new CasdoorAdminException(
|
||||
"casdoor sync not configured: set api-token or sync-username/sync-password"));
|
||||
}
|
||||
return casdoorClient.passwordGrant(props.getSyncUsername(), props.getSyncPassword())
|
||||
.map(r -> {
|
||||
long ttlMillis = (r.getExpiresIn() != null ? r.getExpiresIn() : 3600L) * 1000L;
|
||||
CachedToken next = new CachedToken(r.getAccessToken(), System.currentTimeMillis() + ttlMillis);
|
||||
tokenCache.set(next);
|
||||
log.info("casdoor sync token refreshed: ttl={}s", ttlMillis / 1000);
|
||||
return next.token();
|
||||
});
|
||||
}
|
||||
|
||||
/** apiBase:tokenUri 去掉 /api/login/oauth/access_token 后缀得实例根 */
|
||||
private String apiBase() {
|
||||
String tokenUri = props.getTokenUri();
|
||||
if (tokenUri != null && tokenUri.endsWith(TOKEN_PATH)) {
|
||||
return tokenUri.substring(0, tokenUri.length() - TOKEN_PATH.length());
|
||||
}
|
||||
return tokenUri;
|
||||
}
|
||||
|
||||
private List<CasdoorUserDto> parseUsers(String body) {
|
||||
try {
|
||||
JsonNode root = MAPPER.readTree(body);
|
||||
if (!"ok".equalsIgnoreCase(root.path("status").asText())) {
|
||||
throw new CasdoorAdminException("get-users returned status=" + root.path("status").asText()
|
||||
+ " msg=" + root.path("msg").asText());
|
||||
}
|
||||
List<CasdoorUserDto> users = new ArrayList<>();
|
||||
for (JsonNode node : root.path("data")) {
|
||||
CasdoorUserDto u = new CasdoorUserDto();
|
||||
u.setOwner(node.path("owner").asText(null));
|
||||
u.setName(node.path("name").asText(null));
|
||||
u.setEmail(node.path("email").asText(null));
|
||||
u.setIsForbidden(node.path("isForbidden").isBoolean() ? node.path("isForbidden").asBoolean() : null);
|
||||
u.setIsDisabled(node.path("isDisabled").isBoolean() ? node.path("isDisabled").asBoolean() : null);
|
||||
u.setLegacyDisabled(node.path("is_disabled").isBoolean() ? node.path("is_disabled").asBoolean() : null);
|
||||
List<String> groups = new ArrayList<>();
|
||||
for (JsonNode g : node.path("groups")) {
|
||||
if (g.isTextual() && !g.asText().isBlank()) {
|
||||
groups.add(g.asText());
|
||||
}
|
||||
}
|
||||
u.setGroups(groups.isEmpty() ? null : groups);
|
||||
users.add(u);
|
||||
}
|
||||
return users;
|
||||
} catch (CasdoorAdminException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new CasdoorAdminException("failed to parse get-users response", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static boolean hasText(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
private record CachedToken(String token, long expiresAtMillis) {}
|
||||
|
||||
/** Casdoor 管理 API 调用失败 */
|
||||
public static class CasdoorAdminException extends RuntimeException {
|
||||
public CasdoorAdminException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CasdoorAdminException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -86,6 +86,38 @@ public class CasdoorProperties {
|
||||
/** Flow B:/api/user/login 是否优先走 Casdoor password grant */
|
||||
private PasswordGrant passwordGrant = new PasswordGrant();
|
||||
|
||||
// ---------- M3:账权全面迁入 Casdoor(Casdoor 为账号唯一数据源) ----------
|
||||
|
||||
/**
|
||||
* 账号唯一数据源开关:true 时
|
||||
* <ul>
|
||||
* <li>/api/user/login、/api/user/signup 返回 403(本地账密登录/注册关闭);</li>
|
||||
* <li>定时全量同步 Casdoor 用户 → 本地 users(状态/组/角色),Casdoor 中不存在的本地账号置为禁用。</li>
|
||||
* </ul>
|
||||
* 前端据 /sso/casdoor/config 的 accountOnly 隐藏本地登录/注册入口。
|
||||
*/
|
||||
private boolean accountOnly = false;
|
||||
|
||||
/**
|
||||
* Casdoor 管理 API Token(Bearer 调用 get-users 全量同步)。
|
||||
* 新版 Casdoor 可在控制台「API Tokens」创建(owner 建议 built-in);
|
||||
* 老版无此功能时留空,改走 {@link #syncUsername}/{@link #syncPassword}
|
||||
* password grant 换短期令牌。
|
||||
*/
|
||||
private String apiToken;
|
||||
|
||||
/** 同步认证用户名(password grant;apiToken 为空时使用) */
|
||||
private String syncUsername;
|
||||
|
||||
/** 同步认证密码(password grant;apiToken 为空时使用) */
|
||||
private String syncPassword;
|
||||
|
||||
/** 同步的组织 owner(Casdoor 用户所属组织) */
|
||||
private String orgOwner = "built-in";
|
||||
|
||||
/** 定时同步间隔(分钟) */
|
||||
private int syncIntervalMinutes = 5;
|
||||
|
||||
// ---------- M2:组驱动角色 ----------
|
||||
|
||||
/** id_token 中组列表的 claim 名(Casdoor 应用 tokenFields=["Groups"] 时携带) */
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Casdoor 用户(M3 全量同步用),对应 {@code /api/get-users?owner=...} 返回项。
|
||||
*
|
||||
* <p>只映射同步所需字段;老版 Casdoor 无 {@code isDisabled},禁用标志为
|
||||
* {@code isForbidden}(2026-08-31 实测 31.23 实例),两字段都解析。
|
||||
*
|
||||
* @since 2026/8/31
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class CasdoorUserDto {
|
||||
|
||||
/** 组织 owner,如 built-in */
|
||||
private String owner;
|
||||
|
||||
/** Casdoor 用户名 */
|
||||
private String name;
|
||||
|
||||
private String email;
|
||||
|
||||
/** Casdoor 组列表,如 ["built-in/deepdata-admin", "built-in/deepdata-user"] */
|
||||
private List<String> groups;
|
||||
|
||||
/** 老版禁用标志(forbidden) */
|
||||
@JsonProperty("isForbidden")
|
||||
private Boolean isForbidden;
|
||||
|
||||
/** 新版禁用标志 */
|
||||
@JsonProperty("isDisabled")
|
||||
private Boolean isDisabled;
|
||||
|
||||
@JsonProperty("is_disabled")
|
||||
private Boolean legacyDisabled;
|
||||
|
||||
/** 任一禁用标志为 true → 视为禁用 */
|
||||
public boolean isDisabledOrForbidden() {
|
||||
return Boolean.TRUE.equals(isForbidden)
|
||||
|| Boolean.TRUE.equals(isDisabled)
|
||||
|| Boolean.TRUE.equals(legacyDisabled);
|
||||
}
|
||||
}
|
||||
+66
-5
@@ -1,42 +1,103 @@
|
||||
package com.deepdata.gateway.interfaces.rest;
|
||||
|
||||
import com.deepdata.common.infrastructure.common.Response;
|
||||
import com.deepdata.gateway.common.config.RoleResolver;
|
||||
import com.deepdata.gateway.domain.service.UserService;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorAccountSyncService;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Casdoor SSO 前端配置端点(M2)。
|
||||
* Casdoor SSO 前端配置端点(M2)+ M3 账号同步管理端点。
|
||||
*
|
||||
* <p>前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关与登录/登出入口,
|
||||
* 无需感知后端细节。始终注册(不走 {@code @ConditionalOnProperty}):enabled=false 时
|
||||
* {@code enabled:false},前端自然隐藏 SSO 入口 —— 这是与 OauthController
|
||||
* 「关了就 404」的差异点,接入文档已注明。
|
||||
* <p>前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关、
|
||||
* accountOnly 与登录/登出入口,无需感知后端细节。始终注册(不走
|
||||
* {@code @ConditionalOnProperty}):enabled=false 时 {@code enabled:false},
|
||||
* 前端自然隐藏 SSO 入口。
|
||||
*
|
||||
* <p>M3 {@code POST /sync}:手动触发一次全量账号同步,仅 admin 角色可调
|
||||
* (JWT sub → 本地用户 → 主角色=admin 或存量 role=ADMIN)。
|
||||
*
|
||||
* <p>敏感字段(clientSecret 等)绝不返回给浏览器。
|
||||
*
|
||||
* @since 2026/8/30
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/sso/casdoor")
|
||||
@RequiredArgsConstructor
|
||||
public class CasdoorSsoConfigController {
|
||||
|
||||
private final CasdoorProperties props;
|
||||
private final CasdoorAccountSyncService syncService;
|
||||
private final UserService userService;
|
||||
private final RoleResolver roleResolver;
|
||||
|
||||
@GetMapping("/config")
|
||||
public Response<Map<String, Object>> config() {
|
||||
Map<String, Object> cfg = new LinkedHashMap<>();
|
||||
cfg.put("enabled", props.isEnabled());
|
||||
cfg.put("accountOnly", props.isAccountOnly());
|
||||
cfg.put("loginUrl", "/api/user/oauth/login");
|
||||
cfg.put("logoutUrl", "/api/user/oauth/logout");
|
||||
cfg.put("issuer", props.getIssuer());
|
||||
cfg.put("clientId", props.getClientId());
|
||||
return Response.ok(cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动全量同步(admin only)。
|
||||
*/
|
||||
@PostMapping("/sync")
|
||||
public ResponseEntity<Response<Map<String, Object>>> sync(ServerWebExchange exchange) {
|
||||
if (!props.isEnabled() || !props.isAccountOnly()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new Response<>("casdoor.disabled", "Casdoor 账号同步未启用", null));
|
||||
}
|
||||
String username = currentUsername(exchange);
|
||||
if (username == null || !isAdmin(username)) {
|
||||
log.warn("casdoor sync rejected: caller={}", username);
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new Response<>("casdoor.forbidden", "仅管理员可触发账号同步", null));
|
||||
}
|
||||
return syncService.syncFull()
|
||||
.map(summary -> ResponseEntity.ok(Response.ok(summary)))
|
||||
.onErrorResume(e -> {
|
||||
log.error("casdoor manual sync failed: {}", e.getMessage());
|
||||
return reactor.core.publisher.Mono.just(
|
||||
ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(new Response<>("casdoor.sync_failed",
|
||||
"同步失败: " + e.getMessage(), null)));
|
||||
})
|
||||
.block();
|
||||
}
|
||||
|
||||
private String currentUsername(ServerWebExchange exchange) {
|
||||
String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
return userService.validateToken(authHeader.substring(7));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isAdmin(String username) {
|
||||
return userService.findUserByUsername(username).map(u -> {
|
||||
if (u.getRole() != null && "ADMIN".equalsIgnoreCase(u.getRole())) {
|
||||
return true;
|
||||
}
|
||||
String primary = roleResolver.primaryRole(u.getUserGroups());
|
||||
return "admin".equalsIgnoreCase(primary);
|
||||
}).orElse(false);
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -8,6 +8,7 @@ import com.deepdata.gateway.common.config.RoleResolver;
|
||||
import com.deepdata.gateway.domain.service.UserService;
|
||||
import com.deepdata.gateway.infrastructure.client.OmsExtensionService;
|
||||
import com.deepdata.gateway.infrastructure.client.OmsService;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorProperties;
|
||||
import com.deepdata.gateway.interfaces.dto.LoginRequest;
|
||||
import com.deepdata.gateway.interfaces.dto.LoginResponse;
|
||||
import com.deepdata.gateway.interfaces.dto.RegisterRequest;
|
||||
@@ -46,6 +47,8 @@ public class UserController {
|
||||
private final OmsExtensionService omsExtensionService;
|
||||
/** M2:Casdoor 组 → 主角色 解析 */
|
||||
private final RoleResolver roleResolver;
|
||||
/** M3:accountOnly 时本地登录/注册一律 403 */
|
||||
private final CasdoorProperties casdoorProperties;
|
||||
|
||||
@Value("${deepdata.jwt.enable:false}")
|
||||
private Boolean jwtEnable;
|
||||
@@ -89,6 +92,12 @@ public class UserController {
|
||||
@PostMapping("/login")
|
||||
@IgnoreResponseWrap
|
||||
public ResponseEntity<Response<LoginResponse>> login(@Valid @RequestBody LoginRequest loginRequest) {
|
||||
// M3:Casdoor 为账号唯一数据源时,本地账号密码登录一律 403
|
||||
if (casdoorProperties.isEnabled() && casdoorProperties.isAccountOnly()) {
|
||||
log.warn("Local login rejected (accountOnly): user={}", loginRequest.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new Response<>("casdoor.account_only", "本地登录已关闭,请使用 Casdoor 单点登录", null));
|
||||
}
|
||||
return userApplicationService.login(loginRequest)
|
||||
.map(response -> ResponseEntity.ok(Response.ok(response)))
|
||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -98,6 +107,12 @@ public class UserController {
|
||||
@PostMapping("/signup")
|
||||
@IgnoreResponseWrap
|
||||
public ResponseEntity<Response<LoginResponse>> register(@Valid @RequestBody RegisterRequest registerRequest) {
|
||||
// M3:Casdoor 为账号唯一数据源时,本地注册一律 403
|
||||
if (casdoorProperties.isEnabled() && casdoorProperties.isAccountOnly()) {
|
||||
log.warn("Local signup rejected (accountOnly): user={}", registerRequest.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(new Response<>("casdoor.account_only", "本地注册已关闭,账号由 Casdoor 统一管理", null));
|
||||
}
|
||||
return userApplicationService.register(registerRequest)
|
||||
.map(response -> ResponseEntity.ok(Response.ok(response)))
|
||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
|
||||
@@ -46,6 +46,14 @@ export function Header() {
|
||||
const [currentUser, setCurrentUser] = useState<UserResponse | null>(null);
|
||||
const [authMode, setAuthMode] = useState<'SSO' | 'JWT' | 'NONE'>('NONE');
|
||||
const [userLoading, setUserLoading] = useState(true);
|
||||
// M3:accountOnly(Casdoor 为账号唯一数据源)时隐藏注册入口,登录统一走登录弹窗(仅 Casdoor SSO)
|
||||
const [accountOnly, setAccountOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
get("/sso/casdoor/config")
|
||||
.then((res: any) => setAccountOnly(!!res?.data?.accountOnly))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (values: { username: string; password: string }) => {
|
||||
try {
|
||||
@@ -250,7 +258,8 @@ export function Header() {
|
||||
: t('user.actions.login'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
// M3:accountOnly 时始终打开登录弹窗(内含 Casdoor 单点登录按钮),不走旧 OMS 登录页
|
||||
if (!accountOnly && (authMode === 'SSO' || isSSOAvailable())) {
|
||||
// SSO 模式:跳转到 ME 登录
|
||||
window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||
} else {
|
||||
@@ -259,6 +268,8 @@ export function Header() {
|
||||
}
|
||||
},
|
||||
},
|
||||
// M3:accountOnly 时隐藏「注册」入口(后端 /api/user/signup 亦已 403)
|
||||
...(accountOnly ? [] : [
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
@@ -274,6 +285,7 @@ export function Header() {
|
||||
}
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ interface LoginDialogProps {
|
||||
|
||||
interface SsoConfig {
|
||||
enabled?: boolean;
|
||||
accountOnly?: boolean;
|
||||
loginUrl?: string;
|
||||
logoutUrl?: string;
|
||||
issuer?: string;
|
||||
@@ -43,6 +44,8 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
const [form] = Form.useForm()
|
||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||
const [ssoLoginUrl, setSsoLoginUrl] = useState("")
|
||||
// M3:accountOnly 时后端已 403 本地登录/注册,前端只保留 Casdoor 单点登录
|
||||
const [accountOnly, setAccountOnly] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -50,6 +53,7 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
if (!alive) return;
|
||||
setSsoEnabled(!!cfg.enabled && !!cfg.loginUrl);
|
||||
if (cfg.loginUrl) setSsoLoginUrl(cfg.loginUrl);
|
||||
setAccountOnly(!!cfg.accountOnly);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -89,19 +93,27 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
<div className="mb-4">
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
block
|
||||
icon={<ShieldCheck className="w-4 h-4" />}
|
||||
onClick={handleCasdoorLogin}
|
||||
>
|
||||
{t('user.loginDialog.casdoorLogin')}
|
||||
</Button>
|
||||
{!accountOnly && (
|
||||
<Divider plain style={{ margin: '12px 0' }}>
|
||||
{t('user.loginDialog.or')}
|
||||
</Divider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accountOnly && !ssoEnabled && (
|
||||
<div className="mb-4 text-sm text-amber-600">
|
||||
本地登录已关闭,且 Casdoor 单点登录入口不可用,请联系管理员。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!accountOnly && (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -148,6 +160,7 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user