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 */
|
/** Flow B:/api/user/login 是否优先走 Casdoor password grant */
|
||||||
private PasswordGrant passwordGrant = new PasswordGrant();
|
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:组驱动角色 ----------
|
// ---------- M2:组驱动角色 ----------
|
||||||
|
|
||||||
/** id_token 中组列表的 claim 名(Casdoor 应用 tokenFields=["Groups"] 时携带) */
|
/** 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;
|
package com.deepdata.gateway.interfaces.rest;
|
||||||
|
|
||||||
import com.deepdata.common.infrastructure.common.Response;
|
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 com.deepdata.gateway.infrastructure.casdoor.CasdoorProperties;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Casdoor SSO 前端配置端点(M2)+ M3 账号同步管理端点。
|
* Casdoor SSO 前端配置端点(M2)。
|
||||||
*
|
*
|
||||||
* <p>前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关、
|
* <p>前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关与登录/登出入口,
|
||||||
* accountOnly 与登录/登出入口,无需感知后端细节。始终注册(不走
|
* 无需感知后端细节。始终注册(不走 {@code @ConditionalOnProperty}):enabled=false 时
|
||||||
* {@code @ConditionalOnProperty}):enabled=false 时 {@code enabled:false},
|
* {@code enabled:false},前端自然隐藏 SSO 入口 —— 这是与 OauthController
|
||||||
* 前端自然隐藏 SSO 入口。
|
* 「关了就 404」的差异点,接入文档已注明。
|
||||||
*
|
|
||||||
* <p>M3 {@code POST /sync}:手动触发一次全量账号同步,仅 admin 角色可调
|
|
||||||
* (JWT sub → 本地用户 → 主角色=admin 或存量 role=ADMIN)。
|
|
||||||
*
|
*
|
||||||
* <p>敏感字段(clientSecret 等)绝不返回给浏览器。
|
* <p>敏感字段(clientSecret 等)绝不返回给浏览器。
|
||||||
*
|
*
|
||||||
* @since 2026/8/30
|
* @since 2026/8/30
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/sso/casdoor")
|
@RequestMapping("/sso/casdoor")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CasdoorSsoConfigController {
|
public class CasdoorSsoConfigController {
|
||||||
|
|
||||||
private final CasdoorProperties props;
|
private final CasdoorProperties props;
|
||||||
private final CasdoorAccountSyncService syncService;
|
|
||||||
private final UserService userService;
|
|
||||||
private final RoleResolver roleResolver;
|
|
||||||
|
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public Response<Map<String, Object>> config() {
|
public Response<Map<String, Object>> config() {
|
||||||
Map<String, Object> cfg = new LinkedHashMap<>();
|
Map<String, Object> cfg = new LinkedHashMap<>();
|
||||||
cfg.put("enabled", props.isEnabled());
|
cfg.put("enabled", props.isEnabled());
|
||||||
cfg.put("accountOnly", props.isAccountOnly());
|
|
||||||
cfg.put("loginUrl", "/api/user/oauth/login");
|
cfg.put("loginUrl", "/api/user/oauth/login");
|
||||||
cfg.put("logoutUrl", "/api/user/oauth/logout");
|
cfg.put("logoutUrl", "/api/user/oauth/logout");
|
||||||
cfg.put("issuer", props.getIssuer());
|
cfg.put("issuer", props.getIssuer());
|
||||||
cfg.put("clientId", props.getClientId());
|
cfg.put("clientId", props.getClientId());
|
||||||
return Response.ok(cfg);
|
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.domain.service.UserService;
|
||||||
import com.deepdata.gateway.infrastructure.client.OmsExtensionService;
|
import com.deepdata.gateway.infrastructure.client.OmsExtensionService;
|
||||||
import com.deepdata.gateway.infrastructure.client.OmsService;
|
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.LoginRequest;
|
||||||
import com.deepdata.gateway.interfaces.dto.LoginResponse;
|
import com.deepdata.gateway.interfaces.dto.LoginResponse;
|
||||||
import com.deepdata.gateway.interfaces.dto.RegisterRequest;
|
import com.deepdata.gateway.interfaces.dto.RegisterRequest;
|
||||||
@@ -47,8 +46,6 @@ public class UserController {
|
|||||||
private final OmsExtensionService omsExtensionService;
|
private final OmsExtensionService omsExtensionService;
|
||||||
/** M2:Casdoor 组 → 主角色 解析 */
|
/** M2:Casdoor 组 → 主角色 解析 */
|
||||||
private final RoleResolver roleResolver;
|
private final RoleResolver roleResolver;
|
||||||
/** M3:accountOnly 时本地登录/注册一律 403 */
|
|
||||||
private final CasdoorProperties casdoorProperties;
|
|
||||||
|
|
||||||
@Value("${deepdata.jwt.enable:false}")
|
@Value("${deepdata.jwt.enable:false}")
|
||||||
private Boolean jwtEnable;
|
private Boolean jwtEnable;
|
||||||
@@ -92,12 +89,6 @@ public class UserController {
|
|||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@IgnoreResponseWrap
|
@IgnoreResponseWrap
|
||||||
public ResponseEntity<Response<LoginResponse>> login(@Valid @RequestBody LoginRequest loginRequest) {
|
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)
|
return userApplicationService.login(loginRequest)
|
||||||
.map(response -> ResponseEntity.ok(Response.ok(response)))
|
.map(response -> ResponseEntity.ok(Response.ok(response)))
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||||
@@ -107,12 +98,6 @@ public class UserController {
|
|||||||
@PostMapping("/signup")
|
@PostMapping("/signup")
|
||||||
@IgnoreResponseWrap
|
@IgnoreResponseWrap
|
||||||
public ResponseEntity<Response<LoginResponse>> register(@Valid @RequestBody RegisterRequest registerRequest) {
|
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)
|
return userApplicationService.register(registerRequest)
|
||||||
.map(response -> ResponseEntity.ok(Response.ok(response)))
|
.map(response -> ResponseEntity.ok(Response.ok(response)))
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
|
|||||||
@@ -1,353 +1,341 @@
|
|||||||
import { User, Globe, LogIn, UserPlus, Sparkles, Shield } from "lucide-react"
|
import { User, Globe, LogIn, UserPlus, Sparkles, Shield } from "lucide-react"
|
||||||
import { memo, useState, useEffect, useCallback } from "react";
|
import { memo, useState, useEffect, useCallback } from "react";
|
||||||
import { NavLink } from "react-router";
|
import { NavLink } from "react-router";
|
||||||
import { Button, Dropdown, message } from "antd"
|
import { Button, Dropdown, message } from "antd"
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { LoginDialog } from "./LoginDialog"
|
import { LoginDialog } from "./LoginDialog"
|
||||||
import { SignupDialog } from "./SignupDialog"
|
import { SignupDialog } from "./SignupDialog"
|
||||||
import { post, get } from "@/utils/request.ts";
|
import { post, get } from "@/utils/request.ts";
|
||||||
import { getCachedHomePageUrl, setCachedHomePageUrl } from "@/utils/systemParam";
|
import { getCachedHomePageUrl, setCachedHomePageUrl } from "@/utils/systemParam";
|
||||||
import { getHomePageUrl } from "@/utils/systemParam";
|
import { getHomePageUrl } from "@/utils/systemParam";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
|
|
||||||
interface UserResponse {
|
interface UserResponse {
|
||||||
username: string;
|
username: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
authMode: 'SSO' | 'JWT' | 'NONE';
|
authMode: 'SSO' | 'JWT' | 'NONE';
|
||||||
requireLogin?: boolean; // 是否强制要求登录(由 DEEPDATA_JWT_ENABLE 控制)
|
requireLogin?: boolean; // 是否强制要求登录(由 DEEPDATA_JWT_ENABLE 控制)
|
||||||
role?: string; // Casdoor 主角色:admin/dev/annotator/viewer(由 RoleResolver 解析)
|
role?: string; // Casdoor 主角色:admin/dev/annotator/viewer(由 RoleResolver 解析)
|
||||||
groups?: string[]; // Casdoor 用户组(含 deepdata-<角色> 前缀组)
|
groups?: string[]; // Casdoor 用户组(含 deepdata-<角色> 前缀组)
|
||||||
}
|
}
|
||||||
|
|
||||||
function loginUsingPost(data: any) {
|
function loginUsingPost(data: any) {
|
||||||
return post("/api/user/login", data);
|
return post("/api/user/login", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function signupUsingPost(data: any) {
|
function signupUsingPost(data: any) {
|
||||||
return post("/api/user/signup", data);
|
return post("/api/user/signup", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentUser() {
|
function getCurrentUser() {
|
||||||
return get<UserResponse>("/api/user/me");
|
return get<UserResponse>("/api/user/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepData SSO 登录 URL(根据实际环境修改)
|
// DeepData SSO 登录 URL(根据实际环境修改)
|
||||||
const ME_LOGIN_URL = import.meta.env.VITE_ME_LOGIN_URL || 'https://deepdata.ailab.yilise.com/login';
|
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';
|
const OMS_LOGOUT_URL = import.meta.env.VITE_OMS_LOGOUT_URL || 'https://oms-service/logout';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loginOpen, setLoginOpen] = useState(false)
|
const [loginOpen, setLoginOpen] = useState(false)
|
||||||
const [signupOpen, setSignupOpen] = useState(false)
|
const [signupOpen, setSignupOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentUser, setCurrentUser] = useState<UserResponse | null>(null);
|
const [currentUser, setCurrentUser] = useState<UserResponse | null>(null);
|
||||||
const [authMode, setAuthMode] = useState<'SSO' | 'JWT' | 'NONE'>('NONE');
|
const [authMode, setAuthMode] = useState<'SSO' | 'JWT' | 'NONE'>('NONE');
|
||||||
const [userLoading, setUserLoading] = useState(true);
|
const [userLoading, setUserLoading] = useState(true);
|
||||||
// M3:accountOnly(Casdoor 为账号唯一数据源)时隐藏注册入口,登录统一走登录弹窗(仅 Casdoor SSO)
|
|
||||||
const [accountOnly, setAccountOnly] = useState(false);
|
const handleLogin = async (values: { username: string; password: string }) => {
|
||||||
|
try {
|
||||||
useEffect(() => {
|
setLoading(true);
|
||||||
get("/sso/casdoor/config")
|
const response = await loginUsingPost(values);
|
||||||
.then((res: any) => setAccountOnly(!!res?.data?.accountOnly))
|
// Store the token in localStorage
|
||||||
.catch(() => {});
|
localStorage.setItem('session', JSON.stringify(response.data));
|
||||||
}, []);
|
message.success(t('user.messages.loginSuccess'));
|
||||||
|
setLoginOpen(false);
|
||||||
const handleLogin = async (values: { username: string; password: string }) => {
|
// Optionally refresh the page or update the UI
|
||||||
try {
|
window.location.reload();
|
||||||
setLoading(true);
|
} catch (error) {
|
||||||
const response = await loginUsingPost(values);
|
console.error('Login error:', error);
|
||||||
// Store the token in localStorage
|
message.error(t('user.messages.loginFailed'));
|
||||||
localStorage.setItem('session', JSON.stringify(response.data));
|
} finally {
|
||||||
message.success(t('user.messages.loginSuccess'));
|
setLoading(false);
|
||||||
setLoginOpen(false);
|
}
|
||||||
// Optionally refresh the page or update the UI
|
};
|
||||||
window.location.reload();
|
|
||||||
} catch (error) {
|
const handleSignup = async (values: {
|
||||||
console.error('Login error:', error);
|
username: string;
|
||||||
message.error(t('user.messages.loginFailed'));
|
email: string;
|
||||||
} finally {
|
password: string;
|
||||||
setLoading(false);
|
confirmPassword: string
|
||||||
}
|
}) => {
|
||||||
};
|
if (values.password !== values.confirmPassword) {
|
||||||
|
message.error(t('user.messages.passwordMismatch'));
|
||||||
const handleSignup = async (values: {
|
return;
|
||||||
username: string;
|
}
|
||||||
email: string;
|
|
||||||
password: string;
|
try {
|
||||||
confirmPassword: string
|
setLoading(true);
|
||||||
}) => {
|
const { username, email, password } = values;
|
||||||
if (values.password !== values.confirmPassword) {
|
const response = await signupUsingPost({ username, email, password });
|
||||||
message.error(t('user.messages.passwordMismatch'));
|
|
||||||
return;
|
message.success(t('user.messages.signupSuccess'));
|
||||||
}
|
localStorage.setItem('session', JSON.stringify(response.data));
|
||||||
|
setSignupOpen(false);
|
||||||
try {
|
} catch (error) {
|
||||||
setLoading(true);
|
console.error('Registration error:', error);
|
||||||
const { username, email, password } = values;
|
message.error(t('user.messages.signupFailed'));
|
||||||
const response = await signupUsingPost({ username, email, password });
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
message.success(t('user.messages.signupSuccess'));
|
}
|
||||||
localStorage.setItem('session', JSON.stringify(response.data));
|
};
|
||||||
setSignupOpen(false);
|
|
||||||
} catch (error) {
|
const handleLogout = () => {
|
||||||
console.error('Registration error:', error);
|
if (authMode === 'SSO') {
|
||||||
message.error(t('user.messages.signupFailed'));
|
// SSO 模式:检查是否配置了有效的登出 URL
|
||||||
} finally {
|
const logoutUrl = OMS_LOGOUT_URL;
|
||||||
setLoading(false);
|
|
||||||
}
|
// 如果配置的是默认值(内部 service 名称),只清除本地状态
|
||||||
};
|
if (logoutUrl.includes('oms-service') || logoutUrl.includes('localhost')) {
|
||||||
|
console.warn('OMS logout URL not configured or using internal address, skipping redirect');
|
||||||
const handleLogout = () => {
|
setCurrentUser(null);
|
||||||
if (authMode === 'SSO') {
|
setAuthMode('NONE');
|
||||||
// SSO 模式:检查是否配置了有效的登出 URL
|
message.success(t('user.messages.logoutSuccess'));
|
||||||
const logoutUrl = OMS_LOGOUT_URL;
|
window.location.reload();
|
||||||
|
} else {
|
||||||
// 如果配置的是默认值(内部 service 名称),只清除本地状态
|
// 使用配置的登出 URL
|
||||||
if (logoutUrl.includes('oms-service') || logoutUrl.includes('localhost')) {
|
window.location.href = `${logoutUrl}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||||
console.warn('OMS logout URL not configured or using internal address, skipping redirect');
|
}
|
||||||
setCurrentUser(null);
|
} else {
|
||||||
setAuthMode('NONE');
|
// JWT 模式:清除本地 session
|
||||||
message.success(t('user.messages.logoutSuccess'));
|
localStorage.removeItem('session');
|
||||||
window.location.reload();
|
setCurrentUser(null);
|
||||||
} else {
|
setAuthMode('NONE');
|
||||||
// 使用配置的登出 URL
|
message.success(t('user.messages.logoutSuccess'));
|
||||||
window.location.href = `${logoutUrl}?redirect=${encodeURIComponent(window.location.href)}`;
|
window.location.reload();
|
||||||
}
|
}
|
||||||
} else {
|
};
|
||||||
// JWT 模式:清除本地 session
|
|
||||||
localStorage.removeItem('session');
|
const openLoginDialog = () => {
|
||||||
setCurrentUser(null);
|
setLoginOpen(true);
|
||||||
setAuthMode('NONE');
|
};
|
||||||
message.success(t('user.messages.logoutSuccess'));
|
|
||||||
window.location.reload();
|
const openSignupDialog = () => {
|
||||||
}
|
setSignupOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openLoginDialog = () => {
|
const handleHomeClick = useCallback((e: React.MouseEvent) => {
|
||||||
setLoginOpen(true);
|
const homeUrl = getCachedHomePageUrl();
|
||||||
};
|
if (homeUrl) {
|
||||||
|
e.preventDefault();
|
||||||
const openSignupDialog = () => {
|
window.location.href = homeUrl;
|
||||||
setSignupOpen(true);
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleHomeClick = useCallback((e: React.MouseEvent) => {
|
// 已登录时后台刷新缓存,保持与后端同步
|
||||||
const homeUrl = getCachedHomePageUrl();
|
useEffect(() => {
|
||||||
if (homeUrl) {
|
getHomePageUrl().then(url => setCachedHomePageUrl(url)).catch(() => {});
|
||||||
e.preventDefault();
|
}, []);
|
||||||
window.location.href = homeUrl;
|
|
||||||
}
|
// 检测是否在生产环境(deepdata 域名)
|
||||||
}, []);
|
const isSSOAvailable = () => {
|
||||||
|
const hostname = window.location.hostname;
|
||||||
// 已登录时后台刷新缓存,保持与后端同步
|
// 通过域名或注入的全局变量判断
|
||||||
useEffect(() => {
|
return hostname.includes('deepdata') ||
|
||||||
getHomePageUrl().then(url => setCachedHomePageUrl(url)).catch(() => {});
|
hostname.includes('yilise.com') ||
|
||||||
}, []);
|
(window as any).__DEEPDATA_ENV__ === true;
|
||||||
|
};
|
||||||
// 检测是否在生产环境(deepdata 域名)
|
|
||||||
const isSSOAvailable = () => {
|
// 获取当前用户信息(支持双模式)
|
||||||
const hostname = window.location.hostname;
|
useEffect(() => {
|
||||||
// 通过域名或注入的全局变量判断
|
const fetchCurrentUser = async () => {
|
||||||
return hostname.includes('deepdata') ||
|
try {
|
||||||
hostname.includes('yilise.com') ||
|
const response = await getCurrentUser();
|
||||||
(window as any).__DEEPDATA_ENV__ === true;
|
if (response.data) {
|
||||||
};
|
setCurrentUser(response.data);
|
||||||
|
setAuthMode(response.data.authMode);
|
||||||
// 获取当前用户信息(支持双模式)
|
|
||||||
useEffect(() => {
|
// 如果未登录,根据 requireLogin 决定是否弹出登录框
|
||||||
const fetchCurrentUser = async () => {
|
if (!response.data.authenticated) {
|
||||||
try {
|
if (response.data.requireLogin) {
|
||||||
const response = await getCurrentUser();
|
// 强制要求登录:弹出登录框
|
||||||
if (response.data) {
|
window.dispatchEvent(new CustomEvent('show-login'));
|
||||||
setCurrentUser(response.data);
|
}
|
||||||
setAuthMode(response.data.authMode);
|
// 不强制登录:允许匿名访问
|
||||||
|
}
|
||||||
// 如果未登录,根据 requireLogin 决定是否弹出登录框
|
}
|
||||||
if (!response.data.authenticated) {
|
} catch (error) {
|
||||||
if (response.data.requireLogin) {
|
console.error('Failed to fetch current user:', error);
|
||||||
// 强制要求登录:弹出登录框
|
// 请求失败时,保持未登录状态
|
||||||
window.dispatchEvent(new CustomEvent('show-login'));
|
} finally {
|
||||||
}
|
setUserLoading(false);
|
||||||
// 不强制登录:允许匿名访问
|
}
|
||||||
}
|
};
|
||||||
}
|
|
||||||
} catch (error) {
|
fetchCurrentUser();
|
||||||
console.error('Failed to fetch current user:', error);
|
}, []);
|
||||||
// 请求失败时,保持未登录状态
|
|
||||||
} finally {
|
const languageMenuItems: MenuProps['items'] = [
|
||||||
setUserLoading(false);
|
{
|
||||||
}
|
key: 'zh',
|
||||||
};
|
label: t('header.simplifiedChinese'),
|
||||||
|
onClick: () => {
|
||||||
fetchCurrentUser();
|
i18n.changeLanguage('zh');
|
||||||
}, []);
|
localStorage.setItem('language', 'zh');
|
||||||
|
},
|
||||||
const languageMenuItems: MenuProps['items'] = [
|
},
|
||||||
{
|
{
|
||||||
key: 'zh',
|
key: 'en',
|
||||||
label: t('header.simplifiedChinese'),
|
label: t('header.english'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
i18n.changeLanguage('zh');
|
i18n.changeLanguage('en');
|
||||||
localStorage.setItem('language', 'zh');
|
localStorage.setItem('language', 'en');
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
key: 'en',
|
|
||||||
label: t('header.english'),
|
const userDropdownItems: MenuProps['items'] = currentUser?.authenticated
|
||||||
onClick: () => {
|
? [
|
||||||
i18n.changeLanguage('en');
|
{
|
||||||
localStorage.setItem('language', 'en');
|
key: 'profile',
|
||||||
},
|
label: (
|
||||||
}
|
<div className="flex flex-col">
|
||||||
]
|
<span className="font-medium">{currentUser.username}</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
const userDropdownItems: MenuProps['items'] = currentUser?.authenticated
|
{authMode === 'SSO' ? (
|
||||||
? [
|
<span className="flex items-center gap-1">
|
||||||
{
|
<Shield className="h-3 w-3" />
|
||||||
key: 'profile',
|
{t('user.authMode.sso')}
|
||||||
label: (
|
</span>
|
||||||
<div className="flex flex-col">
|
) : (
|
||||||
<span className="font-medium">{currentUser.username}</span>
|
<span className="flex items-center gap-1">
|
||||||
<span className="text-xs text-gray-500">
|
<Sparkles className="h-3 w-3" />
|
||||||
{authMode === 'SSO' ? (
|
{t('user.authMode.jwt')}
|
||||||
<span className="flex items-center gap-1">
|
</span>
|
||||||
<Shield className="h-3 w-3" />
|
)}
|
||||||
{t('user.authMode.sso')}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
) : (
|
),
|
||||||
<span className="flex items-center gap-1">
|
},
|
||||||
<Sparkles className="h-3 w-3" />
|
currentUser.groupId && {
|
||||||
{t('user.authMode.jwt')}
|
key: 'groupId',
|
||||||
</span>
|
label: `${t('user.group')}: ${currentUser.groupId}`,
|
||||||
)}
|
disabled: true,
|
||||||
</span>
|
},
|
||||||
</div>
|
currentUser.email && {
|
||||||
),
|
key: 'email',
|
||||||
},
|
label: currentUser.email,
|
||||||
currentUser.groupId && {
|
disabled: true,
|
||||||
key: 'groupId',
|
},
|
||||||
label: `${t('user.group')}: ${currentUser.groupId}`,
|
// 只有 JWT 模式才显示退出登录按钮
|
||||||
disabled: true,
|
...(authMode !== 'SSO' ? [
|
||||||
},
|
{
|
||||||
currentUser.email && {
|
type: 'divider',
|
||||||
key: 'email',
|
},
|
||||||
label: currentUser.email,
|
{
|
||||||
disabled: true,
|
key: 'logout',
|
||||||
},
|
label: t('user.actions.logout'),
|
||||||
// 只有 JWT 模式才显示退出登录按钮
|
icon: <LogIn className="h-4 w-4" />,
|
||||||
...(authMode !== 'SSO' ? [
|
onClick: handleLogout,
|
||||||
{
|
},
|
||||||
type: 'divider',
|
] : []),
|
||||||
},
|
]
|
||||||
{
|
: [
|
||||||
key: 'logout',
|
{
|
||||||
label: t('user.actions.logout'),
|
key: 'login',
|
||||||
icon: <LogIn className="h-4 w-4" />,
|
label: authMode === 'SSO' || isSSOAvailable()
|
||||||
onClick: handleLogout,
|
? t('user.actions.gotoLogin')
|
||||||
},
|
: t('user.actions.login'),
|
||||||
] : []),
|
icon: <LogIn className="h-4 w-4" />,
|
||||||
]
|
onClick: () => {
|
||||||
: [
|
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||||
{
|
// SSO 模式:跳转到 ME 登录
|
||||||
key: 'login',
|
window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`;
|
||||||
label: authMode === 'SSO' || isSSOAvailable()
|
} else {
|
||||||
? t('user.actions.gotoLogin')
|
// JWT 模式:显示登录对话框
|
||||||
: t('user.actions.login'),
|
setLoginOpen(true);
|
||||||
icon: <LogIn className="h-4 w-4" />,
|
}
|
||||||
onClick: () => {
|
},
|
||||||
// M3:accountOnly 时始终打开登录弹窗(内含 Casdoor 单点登录按钮),不走旧 OMS 登录页
|
},
|
||||||
if (!accountOnly && (authMode === 'SSO' || isSSOAvailable())) {
|
{
|
||||||
// SSO 模式:跳转到 ME 登录
|
type: 'divider',
|
||||||
window.location.href = `${ME_LOGIN_URL}?redirect=${encodeURIComponent(window.location.href)}`;
|
},
|
||||||
} else {
|
{
|
||||||
// JWT 模式:显示登录对话框
|
key: 'register',
|
||||||
setLoginOpen(true);
|
label: t('user.actions.register'),
|
||||||
}
|
icon: <UserPlus className="h-4 w-4" />,
|
||||||
},
|
onClick: () => {
|
||||||
},
|
if (authMode === 'SSO' || isSSOAvailable()) {
|
||||||
// M3:accountOnly 时隐藏「注册」入口(后端 /api/user/signup 亦已 403)
|
message.info(t('user.messages.useSSO'));
|
||||||
...(accountOnly ? [] : [
|
} else {
|
||||||
{
|
setSignupOpen(true);
|
||||||
type: 'divider',
|
}
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
key: 'register',
|
];
|
||||||
label: t('user.actions.register'),
|
|
||||||
icon: <UserPlus className="h-4 w-4" />,
|
return (
|
||||||
onClick: () => {
|
<>
|
||||||
if (authMode === 'SSO' || isSSOAvailable()) {
|
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
||||||
message.info(t('user.messages.useSSO'));
|
<div className="flex h-14 items-center justify-between px-6">
|
||||||
} else {
|
<div className="flex items-center gap-2">
|
||||||
setSignupOpen(true);
|
<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>
|
||||||
return (
|
*/}
|
||||||
<>
|
<span className="text-lg font-bold text-gray-900">DeepData</span>
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
</NavLink>
|
||||||
<div className="flex h-14 items-center justify-between px-6">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<NavLink to="/" onClick={handleHomeClick} className="flex items-center gap-2 cursor-pointer">
|
<div className="flex items-center gap-4">
|
||||||
{/* xuhuanqing */}
|
<Dropdown
|
||||||
{/*
|
menu={{ items: languageMenuItems }}
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
|
placement="bottomRight"
|
||||||
<Sparkles className="w-5 h-5 text-white" />
|
>
|
||||||
</div>
|
<Button type="text" className="flex items-center gap-2">
|
||||||
*/}
|
<Globe className="h-4 w-4" />
|
||||||
<span className="text-lg font-bold text-gray-900">DeepData</span>
|
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
||||||
</NavLink>
|
</Button>
|
||||||
</div>
|
</Dropdown>
|
||||||
</div>
|
|
||||||
|
<Dropdown
|
||||||
<div className="flex items-center gap-4">
|
menu={{ items: userDropdownItems }}
|
||||||
<Dropdown
|
placement="bottomRight"
|
||||||
menu={{ items: languageMenuItems }}
|
overlayClassName="w-40"
|
||||||
placement="bottomRight"
|
>
|
||||||
>
|
<Button type="text" icon={<User className="h-4 w-4" />} loading={userLoading}>
|
||||||
<Button type="text" className="flex items-center gap-2">
|
{currentUser?.authenticated && !userLoading && (
|
||||||
<Globe className="h-4 w-4" />
|
<span className="ml-1">{currentUser.username}</span>
|
||||||
<span>{i18n.language === 'zh' ? t('header.simplifiedChinese') : t('header.english')}</span>
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</div>
|
||||||
<Dropdown
|
</div>
|
||||||
menu={{ items: userDropdownItems }}
|
</header>
|
||||||
placement="bottomRight"
|
|
||||||
overlayClassName="w-40"
|
<LoginDialog
|
||||||
>
|
open={loginOpen}
|
||||||
<Button type="text" icon={<User className="h-4 w-4" />} loading={userLoading}>
|
onOpenChange={setLoginOpen}
|
||||||
{currentUser?.authenticated && !userLoading && (
|
onLogin={handleLogin}
|
||||||
<span className="ml-1">{currentUser.username}</span>
|
loading={loading}
|
||||||
)}
|
onSignupClick={openSignupDialog}
|
||||||
</Button>
|
/>
|
||||||
</Dropdown>
|
<SignupDialog
|
||||||
</div>
|
open={signupOpen}
|
||||||
</div>
|
onOpenChange={setSignupOpen}
|
||||||
</header>
|
onSignup={handleSignup}
|
||||||
|
loading={loading}
|
||||||
<LoginDialog
|
onLoginClick={openLoginDialog}
|
||||||
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);
|
export default memo(Header);
|
||||||
@@ -14,7 +14,6 @@ interface LoginDialogProps {
|
|||||||
|
|
||||||
interface SsoConfig {
|
interface SsoConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
accountOnly?: boolean;
|
|
||||||
loginUrl?: string;
|
loginUrl?: string;
|
||||||
logoutUrl?: string;
|
logoutUrl?: string;
|
||||||
issuer?: string;
|
issuer?: string;
|
||||||
@@ -44,8 +43,6 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
|||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
const [ssoEnabled, setSsoEnabled] = useState(false)
|
const [ssoEnabled, setSsoEnabled] = useState(false)
|
||||||
const [ssoLoginUrl, setSsoLoginUrl] = useState("")
|
const [ssoLoginUrl, setSsoLoginUrl] = useState("")
|
||||||
// M3:accountOnly 时后端已 403 本地登录/注册,前端只保留 Casdoor 单点登录
|
|
||||||
const [accountOnly, setAccountOnly] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -53,7 +50,6 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
|||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setSsoEnabled(!!cfg.enabled && !!cfg.loginUrl);
|
setSsoEnabled(!!cfg.enabled && !!cfg.loginUrl);
|
||||||
if (cfg.loginUrl) setSsoLoginUrl(cfg.loginUrl);
|
if (cfg.loginUrl) setSsoLoginUrl(cfg.loginUrl);
|
||||||
setAccountOnly(!!cfg.accountOnly);
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
@@ -93,27 +89,19 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
ghost
|
||||||
block
|
block
|
||||||
icon={<ShieldCheck className="w-4 h-4" />}
|
icon={<ShieldCheck className="w-4 h-4" />}
|
||||||
onClick={handleCasdoorLogin}
|
onClick={handleCasdoorLogin}
|
||||||
>
|
>
|
||||||
{t('user.loginDialog.casdoorLogin')}
|
{t('user.loginDialog.casdoorLogin')}
|
||||||
</Button>
|
</Button>
|
||||||
{!accountOnly && (
|
<Divider plain style={{ margin: '12px 0' }}>
|
||||||
<Divider plain style={{ margin: '12px 0' }}>
|
{t('user.loginDialog.or')}
|
||||||
{t('user.loginDialog.or')}
|
</Divider>
|
||||||
</Divider>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{accountOnly && !ssoEnabled && (
|
|
||||||
<div className="mb-4 text-sm text-amber-600">
|
|
||||||
本地登录已关闭,且 Casdoor 单点登录入口不可用,请联系管理员。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!accountOnly && (
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
@@ -160,7 +148,6 @@ export function LoginDialog({ open, onOpenChange, onLogin, loading, onSignupClic
|
|||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user