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