M5: 方法更新移植 —— 移除 accountOnly/定时同步,本地登录与 Casdoor SSO 并存,登录时自动建号
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
- 网关: 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 恢复注册入口恒显
This commit is contained in:
-209
@@ -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 为账号唯一数据源)。
|
||||
*
|
||||
* <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
@@ -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:账号全量同步)。
|
||||
*
|
||||
* <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,38 +86,6 @@ 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
@@ -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=...} 返回项。
|
||||
*
|
||||
* <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);
|
||||
}
|
||||
}
|
||||
+5
-65
@@ -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)。
|
||||
*
|
||||
* <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>前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关与登录/登出入口,
|
||||
* 无需感知后端细节。始终注册(不走 {@code @ConditionalOnProperty}):enabled=false 时
|
||||
* {@code enabled:false},前端自然隐藏 SSO 入口 —— 这是与 OauthController
|
||||
* 「关了就 404」的差异点,接入文档已注明。
|
||||
*
|
||||
* <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)。WebFlux 事件循环线程不可 block,返回 Mono。
|
||||
*/
|
||||
@PostMapping("/sync")
|
||||
public Mono<ResponseEntity<Response<Map<String, Object>>>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
-15
@@ -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<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)
|
||||
@@ -107,12 +98,6 @@ 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)
|
||||
|
||||
@@ -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<UserResponse>("/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<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 {
|
||||
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: (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{currentUser.username}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{authMode === 'SSO' ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
{t('user.authMode.sso')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('user.authMode.jwt')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
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: <LogIn className="h-4 w-4" />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
] : []),
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'login',
|
||||
label: authMode === 'SSO' || isSSOAvailable()
|
||||
? t('user.actions.gotoLogin')
|
||||
: t('user.actions.login'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
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: <UserPlus className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
message.info(t('user.messages.useSSO'));
|
||||
} else {
|
||||
setSignupOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavLink to="/" onClick={handleHomeClick} className="flex items-center gap-2 cursor-pointer">
|
||||
{/* xuhuanqing */}
|
||||
{/*
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
*/}
|
||||
<span className="text-lg font-bold text-gray-900">DeepData</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dropdown
|
||||
menu={{ items: languageMenuItems }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: userDropdownItems }}
|
||||
placement="bottomRight"
|
||||
overlayClassName="w-40"
|
||||
>
|
||||
<Button type="text" icon={<User className="h-4 w-4" />} loading={userLoading}>
|
||||
{currentUser?.authenticated && !userLoading && (
|
||||
<span className="ml-1">{currentUser.username}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
onSignupClick={openSignupDialog}
|
||||
/>
|
||||
<SignupDialog
|
||||
open={signupOpen}
|
||||
onOpenChange={setSignupOpen}
|
||||
onSignup={handleSignup}
|
||||
loading={loading}
|
||||
onLoginClick={openLoginDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<UserResponse>("/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<UserResponse | null>(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: (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{currentUser.username}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{authMode === 'SSO' ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
{t('user.authMode.sso')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('user.authMode.jwt')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
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: <LogIn className="h-4 w-4" />,
|
||||
onClick: handleLogout,
|
||||
},
|
||||
] : []),
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'login',
|
||||
label: authMode === 'SSO' || isSSOAvailable()
|
||||
? t('user.actions.gotoLogin')
|
||||
: t('user.actions.login'),
|
||||
icon: <LogIn className="h-4 w-4" />,
|
||||
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: <UserPlus className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||
message.info(t('user.messages.useSSO'));
|
||||
} else {
|
||||
setSignupOpen(true);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
||||
<div className="flex h-14 items-center justify-between px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<NavLink to="/" onClick={handleHomeClick} className="flex items-center gap-2 cursor-pointer">
|
||||
{/* xuhuanqing */}
|
||||
{/*
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
*/}
|
||||
<span className="text-lg font-bold text-gray-900">DeepData</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Dropdown
|
||||
menu={{ items: languageMenuItems }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button type="text" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: userDropdownItems }}
|
||||
placement="bottomRight"
|
||||
overlayClassName="w-40"
|
||||
>
|
||||
<Button type="text" icon={<User className="h-4 w-4" />} loading={userLoading}>
|
||||
{currentUser?.authenticated && !userLoading && (
|
||||
<span className="ml-1">{currentUser.username}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<LoginDialog
|
||||
open={loginOpen}
|
||||
onOpenChange={setLoginOpen}
|
||||
onLogin={handleLogin}
|
||||
loading={loading}
|
||||
onSignupClick={openSignupDialog}
|
||||
/>
|
||||
<SignupDialog
|
||||
open={signupOpen}
|
||||
onOpenChange={setSignupOpen}
|
||||
onSignup={handleSignup}
|
||||
loading={loading}
|
||||
onLoginClick={openLoginDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Header);
|
||||
@@ -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
|
||||
<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>
|
||||
)}
|
||||
<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"
|
||||
@@ -160,7 +148,6 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user