From 4e2ec551678198e75eaddad665ea83d4be000311 Mon Sep 17 00:00:00 2001 From: figmar Date: Tue, 1 Sep 2026 18:04:28 +0800 Subject: [PATCH] =?UTF-8?q?M5:=20=E6=96=B9=E6=B3=95=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=A7=BB=E6=A4=8D=20=E2=80=94=E2=80=94=20=E7=A7=BB=E9=99=A4=20?= =?UTF-8?q?accountOnly/=E5=AE=9A=E6=97=B6=E5=90=8C=E6=AD=A5=EF=BC=8C?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E7=99=BB=E5=BD=95=E4=B8=8E=20Casdoor=20SSO?= =?UTF-8?q?=20=E5=B9=B6=E5=AD=98=EF=BC=8C=E7=99=BB=E5=BD=95=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=BB=BA=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 网关: CasdoorProperties 移除 accountOnly/sync* 配置块; SsoConfigController 移除 /sync 管理端点; UserController 移除本地登录/注册 403 - 删除 CasdoorAccountSyncService/CasdoorAdminClient/CasdoorUserDto (定时全量同步方案废弃, 由 CasdoorUserService.upsertFromCasdoor 登录时自动建号替代) - 保留既有修复: JJWT 0.12 适配(IdTokenVerifier/validateToken), updateById 修主键, RoleResolver owner/name 组名处理, WebFlux 兼容 - 前端: LoginDialog 移除 accountOnly 条件(本地登录+SSO 按钮恒显), Header 恢复注册入口恒显 --- .../casdoor/CasdoorAccountSyncService.java | 209 ------ .../casdoor/CasdoorAdminClient.java | 162 ---- .../casdoor/CasdoorProperties.java | 32 - .../casdoor/dto/CasdoorUserDto.java | 49 -- .../rest/CasdoorSsoConfigController.java | 70 +- .../interfaces/rest/UserController.java | 15 - frontend/src/pages/Layout/Header.tsx | 692 +++++++++--------- frontend/src/pages/Layout/LoginDialog.tsx | 21 +- 8 files changed, 349 insertions(+), 901 deletions(-) delete mode 100644 backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAccountSyncService.java delete mode 100644 backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAdminClient.java delete mode 100644 backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/dto/CasdoorUserDto.java 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 deleted file mode 100644 index f9184b0..0000000 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAccountSyncService.java +++ /dev/null @@ -1,209 +0,0 @@ -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 deleted file mode 100644 index 199b75f..0000000 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/CasdoorAdminClient.java +++ /dev/null @@ -1,162 +0,0 @@ -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 8396596..53a7a2a 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,38 +86,6 @@ 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 deleted file mode 100644 index c417a4a..0000000 --- a/backend/api-gateway/src/main/java/com/deepdata/gateway/infrastructure/casdoor/dto/CasdoorUserDto.java +++ /dev/null @@ -1,49 +0,0 @@ -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 c18b399..4a8da41 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,102 +1,42 @@ 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 reactor.core.publisher.Mono; import java.util.LinkedHashMap; import java.util.Map; /** - * Casdoor SSO 前端配置端点(M2)+ M3 账号同步管理端点。 + * Casdoor SSO 前端配置端点(M2)。 * - *

前端(登录页 / 菜单壳)通过 {@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)。 + *

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

敏感字段(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)。WebFlux 事件循环线程不可 block,返回 Mono。 - */ - @PostMapping("/sync") - public Mono>>> sync(ServerWebExchange exchange) { - if (!props.isEnabled() || !props.isAccountOnly()) { - return Mono.just(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 Mono.just(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 Mono.just(ResponseEntity.status(HttpStatus.BAD_GATEWAY) - .body(new Response<>("casdoor.sync_failed", - "同步失败: " + e.getMessage(), null))); - }); - } - - 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 b18fb98..b77a010 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,7 +8,6 @@ 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; @@ -47,8 +46,6 @@ 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; @@ -92,12 +89,6 @@ 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) @@ -107,12 +98,6 @@ 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 0ae0c3b..75e5bfd 100644 --- a/frontend/src/pages/Layout/Header.tsx +++ b/frontend/src/pages/Layout/Header.tsx @@ -1,353 +1,341 @@ -import { User, Globe, LogIn, UserPlus, Sparkles, Shield } from "lucide-react" -import { memo, useState, useEffect, useCallback } from "react"; -import { NavLink } from "react-router"; -import { Button, Dropdown, message } from "antd" -import type { MenuProps } from 'antd' -import { LoginDialog } from "./LoginDialog" -import { SignupDialog } from "./SignupDialog" -import { post, get } from "@/utils/request.ts"; -import { getCachedHomePageUrl, setCachedHomePageUrl } from "@/utils/systemParam"; -import { getHomePageUrl } from "@/utils/systemParam"; -import { useTranslation } from "react-i18next"; -import i18n from "@/i18n"; - -interface UserResponse { - username: string; - email?: string; - groupId?: string; - authenticated: boolean; - authMode: 'SSO' | 'JWT' | 'NONE'; - requireLogin?: boolean; // 是否强制要求登录(由 DEEPDATA_JWT_ENABLE 控制) - role?: string; // Casdoor 主角色:admin/dev/annotator/viewer(由 RoleResolver 解析) - groups?: string[]; // Casdoor 用户组(含 deepdata-<角色> 前缀组) -} - -function loginUsingPost(data: any) { - return post("/api/user/login", data); -} - -function signupUsingPost(data: any) { - return post("/api/user/signup", data); -} - -function getCurrentUser() { - return get("/api/user/me"); -} - -// DeepData SSO 登录 URL(根据实际环境修改) -const ME_LOGIN_URL = import.meta.env.VITE_ME_LOGIN_URL || 'https://deepdata.ailab.yilise.com/login'; -const OMS_LOGOUT_URL = import.meta.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout'; - -export function Header() { - const { t } = useTranslation(); - const [loginOpen, setLoginOpen] = useState(false) - const [signupOpen, setSignupOpen] = useState(false) - const [loading, setLoading] = useState(false); - 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 { - setLoading(true); - const response = await loginUsingPost(values); - // Store the token in localStorage - localStorage.setItem('session', JSON.stringify(response.data)); - message.success(t('user.messages.loginSuccess')); - setLoginOpen(false); - // Optionally refresh the page or update the UI - window.location.reload(); - } catch (error) { - console.error('Login error:', error); - message.error(t('user.messages.loginFailed')); - } finally { - setLoading(false); - } - }; - - const handleSignup = async (values: { - username: string; - email: string; - password: string; - confirmPassword: string - }) => { - if (values.password !== values.confirmPassword) { - message.error(t('user.messages.passwordMismatch')); - return; - } - - try { - setLoading(true); - const { username, email, password } = values; - const response = await signupUsingPost({ username, email, password }); - - message.success(t('user.messages.signupSuccess')); - localStorage.setItem('session', JSON.stringify(response.data)); - setSignupOpen(false); - } catch (error) { - console.error('Registration error:', error); - message.error(t('user.messages.signupFailed')); - } finally { - setLoading(false); - } - }; - - const handleLogout = () => { - if (authMode === 'SSO') { - // SSO 模式:检查是否配置了有效的登出 URL - const logoutUrl = OMS_LOGOUT_URL; - - // 如果配置的是默认值(内部 service 名称),只清除本地状态 - if (logoutUrl.includes('oms-service') || logoutUrl.includes('localhost')) { - console.warn('OMS logout URL not configured or using internal address, skipping redirect'); - setCurrentUser(null); - setAuthMode('NONE'); - message.success(t('user.messages.logoutSuccess')); - window.location.reload(); - } else { - // 使用配置的登出 URL - window.location.href = `${logoutUrl}?redirect=${encodeURIComponent(window.location.href)}`; - } - } else { - // JWT 模式:清除本地 session - localStorage.removeItem('session'); - setCurrentUser(null); - setAuthMode('NONE'); - message.success(t('user.messages.logoutSuccess')); - window.location.reload(); - } - }; - - const openLoginDialog = () => { - setLoginOpen(true); - }; - - const openSignupDialog = () => { - setSignupOpen(true); - }; - - const handleHomeClick = useCallback((e: React.MouseEvent) => { - const homeUrl = getCachedHomePageUrl(); - if (homeUrl) { - e.preventDefault(); - window.location.href = homeUrl; - } - }, []); - - // 已登录时后台刷新缓存,保持与后端同步 - useEffect(() => { - getHomePageUrl().then(url => setCachedHomePageUrl(url)).catch(() => {}); - }, []); - - // 检测是否在生产环境(deepdata 域名) - const isSSOAvailable = () => { - const hostname = window.location.hostname; - // 通过域名或注入的全局变量判断 - return hostname.includes('deepdata') || - hostname.includes('yilise.com') || - (window as any).__DEEPDATA_ENV__ === true; - }; - - // 获取当前用户信息(支持双模式) - useEffect(() => { - const fetchCurrentUser = async () => { - try { - const response = await getCurrentUser(); - if (response.data) { - setCurrentUser(response.data); - setAuthMode(response.data.authMode); - - // 如果未登录,根据 requireLogin 决定是否弹出登录框 - if (!response.data.authenticated) { - if (response.data.requireLogin) { - // 强制要求登录:弹出登录框 - window.dispatchEvent(new CustomEvent('show-login')); - } - // 不强制登录:允许匿名访问 - } - } - } catch (error) { - console.error('Failed to fetch current user:', error); - // 请求失败时,保持未登录状态 - } finally { - setUserLoading(false); - } - }; - - fetchCurrentUser(); - }, []); - - const languageMenuItems: MenuProps['items'] = [ - { - key: 'zh', - label: t('header.simplifiedChinese'), - onClick: () => { - i18n.changeLanguage('zh'); - localStorage.setItem('language', 'zh'); - }, - }, - { - key: 'en', - label: t('header.english'), - onClick: () => { - i18n.changeLanguage('en'); - localStorage.setItem('language', 'en'); - }, - } - ] - - const userDropdownItems: MenuProps['items'] = currentUser?.authenticated - ? [ - { - key: 'profile', - label: ( -

- {currentUser.username} - - {authMode === 'SSO' ? ( - - - {t('user.authMode.sso')} - - ) : ( - - - {t('user.authMode.jwt')} - - )} - -
- ), - }, - currentUser.groupId && { - key: 'groupId', - label: `${t('user.group')}: ${currentUser.groupId}`, - disabled: true, - }, - currentUser.email && { - key: 'email', - label: currentUser.email, - disabled: true, - }, - // 只有 JWT 模式才显示退出登录按钮 - ...(authMode !== 'SSO' ? [ - { - type: 'divider', - }, - { - key: 'logout', - label: t('user.actions.logout'), - icon: , - onClick: handleLogout, - }, - ] : []), - ] - : [ - { - key: 'login', - label: authMode === 'SSO' || isSSOAvailable() - ? t('user.actions.gotoLogin') - : t('user.actions.login'), - icon: , - onClick: () => { - // M3:accountOnly 时始终打开登录弹窗(内含 Casdoor 单点登录按钮),不走旧 OMS 登录页 - if (!accountOnly && (authMode === 'SSO' || isSSOAvailable())) { - // SSO 模式:跳转到 ME 登录 - window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`; - } else { - // JWT 模式:显示登录对话框 - setLoginOpen(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 ( - <> -
-
-
-
- - {/* xuhuanqing */} - {/* -
- -
- */} - DeepData -
-
-
- -
- - - - - - - -
-
-
- - - - - ) -} - +import { User, Globe, LogIn, UserPlus, Sparkles, Shield } from "lucide-react" +import { memo, useState, useEffect, useCallback } from "react"; +import { NavLink } from "react-router"; +import { Button, Dropdown, message } from "antd" +import type { MenuProps } from 'antd' +import { LoginDialog } from "./LoginDialog" +import { SignupDialog } from "./SignupDialog" +import { post, get } from "@/utils/request.ts"; +import { getCachedHomePageUrl, setCachedHomePageUrl } from "@/utils/systemParam"; +import { getHomePageUrl } from "@/utils/systemParam"; +import { useTranslation } from "react-i18next"; +import i18n from "@/i18n"; + +interface UserResponse { + username: string; + email?: string; + groupId?: string; + authenticated: boolean; + authMode: 'SSO' | 'JWT' | 'NONE'; + requireLogin?: boolean; // 是否强制要求登录(由 DEEPDATA_JWT_ENABLE 控制) + role?: string; // Casdoor 主角色:admin/dev/annotator/viewer(由 RoleResolver 解析) + groups?: string[]; // Casdoor 用户组(含 deepdata-<角色> 前缀组) +} + +function loginUsingPost(data: any) { + return post("/api/user/login", data); +} + +function signupUsingPost(data: any) { + return post("/api/user/signup", data); +} + +function getCurrentUser() { + return get("/api/user/me"); +} + +// DeepData SSO 登录 URL(根据实际环境修改) +const ME_LOGIN_URL = import.meta.env.VITE_ME_LOGIN_URL || 'https://deepdata.ailab.yilise.com/login'; +const OMS_LOGOUT_URL = import.meta.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout'; + +export function Header() { + const { t } = useTranslation(); + const [loginOpen, setLoginOpen] = useState(false) + const [signupOpen, setSignupOpen] = useState(false) + const [loading, setLoading] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + const [authMode, setAuthMode] = useState<'SSO' | 'JWT' | 'NONE'>('NONE'); + const [userLoading, setUserLoading] = useState(true); + + const handleLogin = async (values: { username: string; password: string }) => { + try { + setLoading(true); + const response = await loginUsingPost(values); + // Store the token in localStorage + localStorage.setItem('session', JSON.stringify(response.data)); + message.success(t('user.messages.loginSuccess')); + setLoginOpen(false); + // Optionally refresh the page or update the UI + window.location.reload(); + } catch (error) { + console.error('Login error:', error); + message.error(t('user.messages.loginFailed')); + } finally { + setLoading(false); + } + }; + + const handleSignup = async (values: { + username: string; + email: string; + password: string; + confirmPassword: string + }) => { + if (values.password !== values.confirmPassword) { + message.error(t('user.messages.passwordMismatch')); + return; + } + + try { + setLoading(true); + const { username, email, password } = values; + const response = await signupUsingPost({ username, email, password }); + + message.success(t('user.messages.signupSuccess')); + localStorage.setItem('session', JSON.stringify(response.data)); + setSignupOpen(false); + } catch (error) { + console.error('Registration error:', error); + message.error(t('user.messages.signupFailed')); + } finally { + setLoading(false); + } + }; + + const handleLogout = () => { + if (authMode === 'SSO') { + // SSO 模式:检查是否配置了有效的登出 URL + const logoutUrl = OMS_LOGOUT_URL; + + // 如果配置的是默认值(内部 service 名称),只清除本地状态 + if (logoutUrl.includes('oms-service') || logoutUrl.includes('localhost')) { + console.warn('OMS logout URL not configured or using internal address, skipping redirect'); + setCurrentUser(null); + setAuthMode('NONE'); + message.success(t('user.messages.logoutSuccess')); + window.location.reload(); + } else { + // 使用配置的登出 URL + window.location.href = `${logoutUrl}?redirect=${encodeURIComponent(window.location.href)}`; + } + } else { + // JWT 模式:清除本地 session + localStorage.removeItem('session'); + setCurrentUser(null); + setAuthMode('NONE'); + message.success(t('user.messages.logoutSuccess')); + window.location.reload(); + } + }; + + const openLoginDialog = () => { + setLoginOpen(true); + }; + + const openSignupDialog = () => { + setSignupOpen(true); + }; + + const handleHomeClick = useCallback((e: React.MouseEvent) => { + const homeUrl = getCachedHomePageUrl(); + if (homeUrl) { + e.preventDefault(); + window.location.href = homeUrl; + } + }, []); + + // 已登录时后台刷新缓存,保持与后端同步 + useEffect(() => { + getHomePageUrl().then(url => setCachedHomePageUrl(url)).catch(() => {}); + }, []); + + // 检测是否在生产环境(deepdata 域名) + const isSSOAvailable = () => { + const hostname = window.location.hostname; + // 通过域名或注入的全局变量判断 + return hostname.includes('deepdata') || + hostname.includes('yilise.com') || + (window as any).__DEEPDATA_ENV__ === true; + }; + + // 获取当前用户信息(支持双模式) + useEffect(() => { + const fetchCurrentUser = async () => { + try { + const response = await getCurrentUser(); + if (response.data) { + setCurrentUser(response.data); + setAuthMode(response.data.authMode); + + // 如果未登录,根据 requireLogin 决定是否弹出登录框 + if (!response.data.authenticated) { + if (response.data.requireLogin) { + // 强制要求登录:弹出登录框 + window.dispatchEvent(new CustomEvent('show-login')); + } + // 不强制登录:允许匿名访问 + } + } + } catch (error) { + console.error('Failed to fetch current user:', error); + // 请求失败时,保持未登录状态 + } finally { + setUserLoading(false); + } + }; + + fetchCurrentUser(); + }, []); + + const languageMenuItems: MenuProps['items'] = [ + { + key: 'zh', + label: t('header.simplifiedChinese'), + onClick: () => { + i18n.changeLanguage('zh'); + localStorage.setItem('language', 'zh'); + }, + }, + { + key: 'en', + label: t('header.english'), + onClick: () => { + i18n.changeLanguage('en'); + localStorage.setItem('language', 'en'); + }, + } + ] + + const userDropdownItems: MenuProps['items'] = currentUser?.authenticated + ? [ + { + key: 'profile', + label: ( +
+ {currentUser.username} + + {authMode === 'SSO' ? ( + + + {t('user.authMode.sso')} + + ) : ( + + + {t('user.authMode.jwt')} + + )} + +
+ ), + }, + currentUser.groupId && { + key: 'groupId', + label: `${t('user.group')}: ${currentUser.groupId}`, + disabled: true, + }, + currentUser.email && { + key: 'email', + label: currentUser.email, + disabled: true, + }, + // 只有 JWT 模式才显示退出登录按钮 + ...(authMode !== 'SSO' ? [ + { + type: 'divider', + }, + { + key: 'logout', + label: t('user.actions.logout'), + icon: , + onClick: handleLogout, + }, + ] : []), + ] + : [ + { + key: 'login', + label: authMode === 'SSO' || isSSOAvailable() + ? t('user.actions.gotoLogin') + : t('user.actions.login'), + icon: , + onClick: () => { + if (authMode === 'SSO' || isSSOAvailable()) { + // SSO 模式:跳转到 ME 登录 + window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`; + } else { + // JWT 模式:显示登录对话框 + setLoginOpen(true); + } + }, + }, + { + 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 ( + <> +
+
+
+
+ + {/* xuhuanqing */} + {/* +
+ +
+ */} + DeepData +
+
+
+ +
+ + + + + + + +
+
+
+ + + + + ) +} + export default memo(Header); \ No newline at end of file diff --git a/frontend/src/pages/Layout/LoginDialog.tsx b/frontend/src/pages/Layout/LoginDialog.tsx index 65d2085..65f7bd1 100644 --- a/frontend/src/pages/Layout/LoginDialog.tsx +++ b/frontend/src/pages/Layout/LoginDialog.tsx @@ -14,7 +14,6 @@ interface LoginDialogProps { interface SsoConfig { enabled?: boolean; - accountOnly?: boolean; loginUrl?: string; logoutUrl?: string; issuer?: string; @@ -44,8 +43,6 @@ 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; @@ -53,7 +50,6 @@ 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; @@ -93,27 +89,19 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
- {!accountOnly && ( - - {t('user.loginDialog.or')} - - )} + + {t('user.loginDialog.or')} +
)} - {accountOnly && !ssoEnabled && ( -
- 本地登录已关闭,且 Casdoor 单点登录入口不可用,请联系管理员。 -
- )} - - {!accountOnly && (
- )} ) } \ No newline at end of file