diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/domain/service/UserService.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/domain/service/UserService.java index 7718b60..68b2bc9 100644 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/domain/service/UserService.java +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/domain/service/UserService.java @@ -82,10 +82,18 @@ public class UserService { return builder.compact(); } + /** + * 校验本地 JWT。M3 起额外校验用户状态:用户不存在或已禁用(本地/同步置禁) + * 返回 null → 网关 AuthFilter 直接 401,禁用账号的存量 token 立即失效。 + */ public String validateToken(String token) { try { Jws 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; } diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAccountSyncService.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAccountSyncService.java new file mode 100644 index 0000000..f9184b0 --- /dev/null +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAccountSyncService.java @@ -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 为账号唯一数据源)。 + * + *

语义(每次运行): + *

+ * + *

触发:定时({@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> syncFull() { + return adminClient.listUsers() + .flatMap(remotes -> Mono.fromCallable(() -> applySync(remotes)) + .subscribeOn(Schedulers.boundedElastic())); + } + + private Map applySync(List remotes) { + if (!running.compareAndSet(false, true)) { + Map skip = new LinkedHashMap<>(); + skip.put("skipped", "already running"); + return skip; + } + try { + Map locals = new HashMap<>(); + for (User u : userRepository.list()) { + if (u.getUsername() != null) { + locals.put(u.getUsername(), u); + } + } + LocalDateTime now = LocalDateTime.now(); + Set 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 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 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); + } + } +} diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAdminClient.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAdminClient.java new file mode 100644 index 0000000..199b75f --- /dev/null +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAdminClient.java @@ -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:账号全量同步)。 + * + *

鉴权两档: + *

+ * + *

用户列表端点:老版实例无 {@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 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> 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 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 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 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 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); + } + } +} diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorProperties.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorProperties.java index 53a7a2a..8396596 100644 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorProperties.java +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorProperties.java @@ -86,6 +86,38 @@ public class CasdoorProperties { /** Flow B:/api/user/login 是否优先走 Casdoor password grant */ private PasswordGrant passwordGrant = new PasswordGrant(); + // ---------- M3:账权全面迁入 Casdoor(Casdoor 为账号唯一数据源) ---------- + + /** + * 账号唯一数据源开关:true 时 + *

+ * 前端据 /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"] 时携带) */ diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/dto/CasdoorUserDto.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/dto/CasdoorUserDto.java new file mode 100644 index 0000000..c417a4a --- /dev/null +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/dto/CasdoorUserDto.java @@ -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=...} 返回项。 + * + *

只映射同步所需字段;老版 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 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); + } +} diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/CasdoorSsoConfigController.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/CasdoorSsoConfigController.java index 4a8da41..08731b9 100644 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/CasdoorSsoConfigController.java +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/CasdoorSsoConfigController.java @@ -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 账号同步管理端点。 * - *

前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关与登录/登出入口, - * 无需感知后端细节。始终注册(不走 {@code @ConditionalOnProperty}):enabled=false 时 - * {@code enabled:false},前端自然隐藏 SSO 入口 —— 这是与 OauthController - * 「关了就 404」的差异点,接入文档已注明。 + *

前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关、 + * accountOnly 与登录/登出入口,无需感知后端细节。始终注册(不走 + * {@code @ConditionalOnProperty}):enabled=false 时 {@code enabled:false}, + * 前端自然隐藏 SSO 入口。 + * + *

M3 {@code POST /sync}:手动触发一次全量账号同步,仅 admin 角色可调 + * (JWT sub → 本地用户 → 主角色=admin 或存量 role=ADMIN)。 * *

敏感字段(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> config() { Map 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>> 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); + } } diff --git a/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/UserController.java b/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/UserController.java index b77a010..b18fb98 100644 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/UserController.java +++ b/backend/api-gateway/src/main/java/com/deepdata/gateway/interfaces/rest/UserController.java @@ -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> 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> 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) diff --git a/frontend/src/pages/Layout/Header.tsx b/frontend/src/pages/Layout/Header.tsx index 14addfd..0ae0c3b 100644 --- a/frontend/src/pages/Layout/Header.tsx +++ b/frontend/src/pages/Layout/Header.tsx @@ -46,6 +46,14 @@ export function Header() { const [currentUser, setCurrentUser] = useState(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: , 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,21 +268,24 @@ export function Header() { } }, }, - { - type: 'divider', - }, - { - key: 'register', - label: t('user.actions.register'), - icon: , - onClick: () => { - if (authMode === 'SSO' || isSSOAvailable()) { - message.info(t('user.messages.useSSO')); - } else { - setSignupOpen(true); - } + // M3:accountOnly 时隐藏「注册」入口(后端 /api/user/signup 亦已 403) + ...(accountOnly ? [] : [ + { + type: 'divider', }, - }, + { + key: 'register', + label: t('user.actions.register'), + icon: , + onClick: () => { + if (authMode === 'SSO' || isSSOAvailable()) { + message.info(t('user.messages.useSSO')); + } else { + setSignupOpen(true); + } + }, + }, + ]), ]; return ( diff --git a/frontend/src/pages/Layout/LoginDialog.tsx b/frontend/src/pages/Layout/LoginDialog.tsx index 65f7bd1..65d2085 100644 --- a/frontend/src/pages/Layout/LoginDialog.tsx +++ b/frontend/src/pages/Layout/LoginDialog.tsx @@ -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

- - {t('user.loginDialog.or')} - + {!accountOnly && ( + + {t('user.loginDialog.or')} + + )}
)} + {accountOnly && !ssoEnabled && ( +
+ 本地登录已关闭,且 Casdoor 单点登录入口不可用,请联系管理员。 +
+ )} + + {!accountOnly && (
+ )} ) } \ No newline at end of file