feat(deepdata): 引入修改密码、文件预览组件与清洗算子增强,deepflow 改名为 deep-flow
本次同步 09-12 至 09-28 的代码变更,主要内容: 新增功能 - 修改密码:后端新增 ChangePasswordRequest DTO 与UserController/UserService 对应接口,前端新增 ChangePasswordDialog 并在 Header 接入入口 - 文件预览组件重构:file-preview 目录拆分出 VideoPreview(支持 blobUrl 直传)、 ImagePreview、AudioPreview,index.tsx 统一调度各类型预览器 - 新增 my_ops 算子模块:markitdown_parser、mineru_pdf_parser、text_quality_evaluator - 新增 tools/minio-file-server MinIO 文件服务 功能增强 - 数据清洗:cleaning_task_service/scheduler/routes 重构, data_juicer_executor 与 wrapper 增强,OperatorTable/LogsTable/TaskDetail 同步调整 - 数据标注:annotation sync.py、project.py 接口与schema 更新 - 数据采集、算子市场、首页与菜单路由相关页面调整 - DataJuicer 算子接入(useOperatorOperations、dj_ops_schema) 结构调整 - deepflow/ 更名为 deep-flow/(419 个文件重命名) - 新增 helm 部署编排、GitHub Actions 之外的自建镜像构建脚本 未纳入本次提交 - scripts-backup/、runtime/python-executor-backup/、components_backup/(备份目录) - *.backup*、__pycache__ 产物 - zh_core_web_sm-3.8.0-py3-none-any.whl(46MB 构建产物) - DataX/(内嵌独立仓库,远程亦未收录其源码)
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
-- =============================================================
|
||||
-- 001_user_groups.sql — M2: users 表新增 user_groups 列
|
||||
--
|
||||
-- 背景:Casdoor 账权接入后,本地用户需保存 Casdoor 组列表
|
||||
-- (users.user_groups JSON),用于组驱动目录菜单体系的角色解析
|
||||
-- (见 RoleResolver / CasdoorUserService)。
|
||||
--
|
||||
-- 说明:
|
||||
-- * api-gateway 未引入 Flyway/Liquibase,本迁移需手工执行:
|
||||
-- psql -h <host> -p 5433 -U <user> -d <db> -f 001_user_groups.sql
|
||||
-- * 幂等:ALTER TABLE ... ADD COLUMN IF NOT EXISTS,可重复执行。
|
||||
-- * 与 scripts/db/data-management-init.sql 的 users 表定义保持同步
|
||||
-- (新库直接建表即可,无需跑本迁移)。
|
||||
-- =============================================================
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS user_groups JSON;
|
||||
|
||||
COMMENT ON COLUMN users.user_groups IS 'Casdoor 组列表(JSON),如 ["deepdata-admin","deepdata-dev"];组驱动角色解析源(M2)';
|
||||
@@ -20,7 +20,7 @@
|
||||
<properties>
|
||||
<spring-boot.version>3.5.6</spring-boot.version>
|
||||
<spring-cloud.version>2025.0.0</spring-cloud.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
<jjwt.version>0.11.5</jjwt.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
@@ -1,97 +1,90 @@
|
||||
package com.deepdata.gateway;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.gateway.route.RouteLocator;
|
||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.FilterType;
|
||||
import org.springframework.context.annotation.ComponentScan.Filter;
|
||||
|
||||
/**
|
||||
* API Gateway & Auth Service Application
|
||||
* 统一的API网关和认证授权微服务
|
||||
* 提供路由、鉴权、限流等功能
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ComponentScan(
|
||||
basePackages = {"com.deepdata"},
|
||||
excludeFilters = @Filter(
|
||||
type = FilterType.REGEX,
|
||||
pattern = "com\\.deepdata\\.common\\.infrastructure\\.config\\..*"
|
||||
)
|
||||
)
|
||||
@MapperScan(basePackages = {"com.deepdata.**.mapper"})
|
||||
public class ApiGatewayApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ApiGatewayApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
||||
return builder.routes()
|
||||
// 数据合成服务路由
|
||||
.route("data-synthesis", r -> r.path("/api/synthesis/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据标注服务路由
|
||||
.route("data-annotation", r -> r.path("/api/annotation/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据评估服务路由
|
||||
.route("data-evaluation", r -> r.path("/api/evaluation/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据归集服务路由
|
||||
.route("data-collection", r -> r.path("/api/data-collection/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 知识图谱RAG服务路由
|
||||
.route("python-service", r -> r.path("/api/rag/**", "api/models/**", "/api/knowledge-base/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据评估服务路由
|
||||
.route("data-operator", r -> r.path("/api/operators/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("data-categories", r -> r.path("/api/categories/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("data-cleaning", r -> r.path("/api/cleaning/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("data-setting", r -> r.path("/api/sys-param/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("deepflow-frontend", r -> r.path("/chat/**")
|
||||
.uri("http://deepflow-frontend:3000"))
|
||||
|
||||
.route("deepflow-static", r -> r.path("/_next/**")
|
||||
.uri("http://deepflow-frontend:3000"))
|
||||
|
||||
.route("deepflow-backend", r -> r.path("/deepflow-backend/**")
|
||||
// M4: 只剥离 /deepflow-backend 前缀。deepflow FastAPI 路由在容器根 /api/*
|
||||
// (deepflow/src/server/app.py),追加 prefixPath("/api") 会把
|
||||
// /deepflow-backend/api/config 改写成 /api/api/config(历史遗留,已删除)
|
||||
.filters(f -> f.stripPrefix(1))
|
||||
.uri("http://deepflow-backend:8000"))
|
||||
|
||||
// 网关内部服务(用户)
|
||||
// 使用 no-op 触发 GlobalFilter 执行,然后由本地 Controller 处理
|
||||
.route("gateway", r -> r.path("/api/user/**")
|
||||
.uri("http://localhost:8080"))
|
||||
|
||||
// M2: Casdoor SSO 前端配置端点(/sso/casdoor/config)同源直通本地 Controller
|
||||
.route("casdoor-sso-config", r -> r.path("/sso/**")
|
||||
.uri("http://localhost:8080"))
|
||||
|
||||
// 其他后端服务
|
||||
.route("default", r -> r.path("/api/**")
|
||||
.uri("http://deepdata-backend:8080"))
|
||||
|
||||
.build();
|
||||
}
|
||||
}
|
||||
package com.deepdata.gateway;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.gateway.route.RouteLocator;
|
||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.context.annotation.FilterType;
|
||||
import org.springframework.context.annotation.ComponentScan.Filter;
|
||||
|
||||
/**
|
||||
* API Gateway & Auth Service Application
|
||||
* 统一的API网关和认证授权微服务
|
||||
* 提供路由、鉴权、限流等功能
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ComponentScan(
|
||||
basePackages = {"com.deepdata"},
|
||||
excludeFilters = @Filter(
|
||||
type = FilterType.REGEX,
|
||||
pattern = "com\\.deepdata\\.common\\.infrastructure\\.config\\..*"
|
||||
)
|
||||
)
|
||||
@MapperScan(basePackages = {"com.deepdata.**.mapper"})
|
||||
public class ApiGatewayApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ApiGatewayApplication.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
|
||||
return builder.routes()
|
||||
// 数据合成服务路由
|
||||
.route("data-synthesis", r -> r.path("/api/synthesis/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据标注服务路由
|
||||
.route("data-annotation", r -> r.path("/api/annotation/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据评估服务路由
|
||||
.route("data-evaluation", r -> r.path("/api/evaluation/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据归集服务路由
|
||||
.route("data-collection", r -> r.path("/api/data-collection/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 知识图谱RAG服务路由
|
||||
.route("python-service", r -> r.path("/api/rag/**", "api/models/**", "/api/knowledge-base/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
// 数据评估服务路由
|
||||
.route("data-operator", r -> r.path("/api/operators/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("data-categories", r -> r.path("/api/categories/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("data-cleaning", r -> r.path("/api/cleaning/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("data-setting", r -> r.path("/api/sys-param/**")
|
||||
.uri("http://deepdata-backend-python:18000"))
|
||||
|
||||
.route("deep-flow-frontend", r -> r.path("/chat/**")
|
||||
.uri("http://deep-flow-frontend:3000"))
|
||||
|
||||
.route("deep-flow-static", r -> r.path("/_next/**")
|
||||
.uri("http://deep-flow-frontend:3000"))
|
||||
|
||||
.route("deep-flow-backend", r -> r.path("/deep-flow-backend/**")
|
||||
.filters(f -> f.stripPrefix(1).prefixPath("/api"))
|
||||
.uri("http://deep-flow-backend:8000"))
|
||||
|
||||
// 网关内部服务(用户)
|
||||
// 使用 no-op 触发 GlobalFilter 执行,然后由本地 Controller 处理
|
||||
.route("gateway", r -> r.path("/api/user/**")
|
||||
.uri("http://localhost:8080"))
|
||||
|
||||
// 其他后端服务
|
||||
.route("default", r -> r.path("/api/**")
|
||||
.uri("http://deepdata-backend:8080"))
|
||||
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -7,6 +7,8 @@ import com.deepdata.gateway.interfaces.dto.LoginResponse;
|
||||
import com.deepdata.gateway.interfaces.dto.RegisterRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.deepdata.gateway.interfaces.dto.ChangePasswordRequest;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -28,7 +30,7 @@ public class UserApplicationService {
|
||||
User user = new User();
|
||||
user.setUsername(loginRequest.getUsername());
|
||||
user.setPassword(loginRequest.getPassword());
|
||||
|
||||
|
||||
Optional<User> authenticatedUser = userService.authenticate(user);
|
||||
if (authenticatedUser.isPresent()) {
|
||||
User userEntity = authenticatedUser.get();
|
||||
@@ -41,7 +43,8 @@ public class UserApplicationService {
|
||||
* Register a new user
|
||||
*
|
||||
* @param registerRequest registration request
|
||||
* @return LoginResponse with user details and token if registration successful, empty otherwise
|
||||
* @return LoginResponse with user details and token if registration successful,
|
||||
* empty otherwise
|
||||
*/
|
||||
public Optional<LoginResponse> register(RegisterRequest registerRequest) {
|
||||
return userService.register(registerRequest)
|
||||
@@ -56,4 +59,8 @@ public class UserApplicationService {
|
||||
.token(user.getToken())
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean changePassword(String username, ChangePasswordRequest request) {
|
||||
return userService.changePassword(username, request.getOldPassword(), request.getNewPassword());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public class JwtConfig {
|
||||
2. Set environment variable:
|
||||
export JWT_SECRET=your-strong-secret-key-here
|
||||
3. Run with system property:
|
||||
-Ddeepdata.jwt.secret=your-strong-secret-key-here"""
|
||||
-DDeepData.jwt.secret=your-strong-secret-key-here"""
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
package com.deepdata.gateway.common.config;
|
||||
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Casdoor 组 → DeepData 角色 解析器(M2,组驱动目录菜单体系核心)。
|
||||
*
|
||||
* <p>组名约定:{@code groupPrefix + 角色键}(默认 {@code deepdata-admin / deepdata-dev / ...})。
|
||||
*
|
||||
* <p>解析规则:
|
||||
* <ul>
|
||||
* <li>{@link #primaryRole}:组名按角色键(大小写不敏感)匹配
|
||||
* {@code CasdoorProperties.rolePriority}(admin=4 > dev=3 > annotator=2 > viewer=1),
|
||||
* 取权重最高者为「主角色」;无已知组返回 null(前端按 viewer-minimal 展示,不 403)。</li>
|
||||
* <li>{@link #resolveRole}:显式 {@code usernameRoleMapping} 命中 > 组主角色 > defaultRole。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @since 2026/8/30
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class RoleResolver {
|
||||
|
||||
private final CasdoorProperties props;
|
||||
|
||||
/**
|
||||
* 从 Casdoor 组列表解析主角色键(权重最高者,小写)。
|
||||
*
|
||||
* @param groups 原始组名,如 ["deepdata-admin", "deepdata-dev"]
|
||||
* @return 已知角色键;无已知组 / 组为空 / 未配置优先级返回 null
|
||||
*/
|
||||
public String primaryRole(Collection<String> groups) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Integer> priority = props.getRolePriority();
|
||||
if (priority == null || priority.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String prefix = props.getGroupPrefix() == null ? "" : props.getGroupPrefix();
|
||||
String best = null;
|
||||
int bestWeight = Integer.MIN_VALUE;
|
||||
for (String g : groups) {
|
||||
String key = roleKey(g, prefix);
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
Integer w = priority.get(key);
|
||||
if (w != null && w > bestWeight) {
|
||||
bestWeight = w;
|
||||
best = key;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整角色解析:显式用户名映射 > 组主角色 > defaultRole。
|
||||
* 登录路径(/me 与 Casdoor upsert)统一走这里,保证角色口径一致。
|
||||
*/
|
||||
public String resolveRole(String username, Collection<String> groups) {
|
||||
Map<String, String> mapping = props.getUsernameRoleMapping();
|
||||
if (username != null && mapping != null && mapping.containsKey(username)) {
|
||||
return mapping.get(username);
|
||||
}
|
||||
String primary = primaryRole(groups);
|
||||
if (primary != null) {
|
||||
return primary;
|
||||
}
|
||||
return props.getDefaultRole();
|
||||
}
|
||||
|
||||
/**
|
||||
* 组名 → 角色键:命中前缀返回后缀小写(如 deepdata-admin → admin),否则 null。
|
||||
* 前缀为空时按整名小写匹配(兼容 group-prefix 留空的自定义组名)。
|
||||
*
|
||||
* <p>Casdoor 的 user.groups 存的是 {@code owner/name} 全 ID(如 {@code built-in/deepdata-admin}),
|
||||
* 开启 useGroupPathInToken 时更可能是多级组路径;统一取最后一段作为组名再匹配。
|
||||
*/
|
||||
private String roleKey(String group, String prefix) {
|
||||
if (group == null || group.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String g = group.trim();
|
||||
int slash = g.lastIndexOf('/');
|
||||
if (slash >= 0) {
|
||||
g = g.substring(slash + 1);
|
||||
}
|
||||
if (prefix.isEmpty()) {
|
||||
return g.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
if (g.length() > prefix.length()
|
||||
&& g.regionMatches(true, 0, prefix, 0, prefix.length())) {
|
||||
return g.substring(prefix.length()).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+39
-74
@@ -23,37 +23,12 @@ import reactor.core.publisher.Mono;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 用户数据隔离过滤器
|
||||
*
|
||||
* 支持两种场景:
|
||||
* 1. 商业场景(SSO):OmsAuthFilter 已添加 X-User-Name header,直接使用
|
||||
* (M2 起仅当 oms.auth.enabled=true 时信任该 header,防止外部伪造用户头)
|
||||
* 2. 独立场景(可选登录):
|
||||
* - DEEPDATA_JWT_ENABLED=true:必须登录,验证 JWT token 并添加 User header
|
||||
* - DEEPDATA_JWT_ENABLED=false:允许匿名访问,不添加 User header
|
||||
*
|
||||
* 优先级:SSO > JWT
|
||||
* Order: 2 (低于 OmsAuthFilter 的 Order=1)
|
||||
*
|
||||
* 公开端点(M2 扩展):/api/user/login|signup、/api/user/me、
|
||||
* /api/user/oauth/**(Casdoor SSO)、/sso/casdoor/config(SSO 前端配置)
|
||||
*
|
||||
* 环境变量:
|
||||
* - OMS_AUTH_ENABLED:是否启用 OmsAuthFilter(商业场景);同时决定 X-User-Name 是否可信
|
||||
* - DEEPDATA_JWT_ENABLE:独立场景下是否要求用户登录
|
||||
*
|
||||
* @author songyongtan
|
||||
* @date 2026-03-30
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AuthFilter implements GlobalFilter, Ordered {
|
||||
private static final String AUTH_HEADER = "Authorization";
|
||||
|
||||
private static final String TOKEN_PREFIX = "Bearer ";
|
||||
|
||||
private static final String USER_HEADER = "User";
|
||||
|
||||
private final UserService userService;
|
||||
@@ -61,78 +36,77 @@ public class AuthFilter implements GlobalFilter, Ordered {
|
||||
@Value("${deepdata.jwt.enable:false}")
|
||||
private Boolean jwtEnable;
|
||||
|
||||
/** M2:商业 SSO 开关;false 时不信任 X-User-Name(防伪造用户头) */
|
||||
@Value("${oms.auth.enabled:false}")
|
||||
private Boolean omsAuthEnabled;
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
String path = request.getURI().getPath();
|
||||
|
||||
// 公开接口:直接放行(M2 扩展 Casdoor SSO 端点 + 前端 SSO 配置端点)
|
||||
if (path.equals("/api/user/login") || path.equals("/api/user/signup")
|
||||
|| path.startsWith("/api/user/oauth/")
|
||||
|| path.equals("/sso/casdoor/config")) {
|
||||
// 1. 公开接口直接放行
|
||||
if (path.equals("/api/user/login") || path.equals("/api/user/signup")) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 内部接口:/api/user/me 内部会自行验证 SSO 或 JWT,直接放行
|
||||
if (path.equals("/api/user/me")) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
try {
|
||||
// 优先检查 SSO 模式(OmsAuthFilter 已添加的 header)
|
||||
// M2:仅在 oms.auth.enabled=true 时信任 X-User-Name,防止外部伪造
|
||||
String ssoUser = null;
|
||||
if (Boolean.TRUE.equals(omsAuthEnabled)) {
|
||||
ssoUser = request.getHeaders().getFirst("X-User-Name");
|
||||
}
|
||||
// 2. 优先检查 SSO 模式 (如果有 X-User-Name Header)
|
||||
String ssoUser = request.getHeaders().getFirst("X-User-Name");
|
||||
if (StringUtils.isNotBlank(ssoUser)) {
|
||||
final String ssoUserFinal = ssoUser;
|
||||
log.info("SSO mode detected, adding User header: {}", ssoUser);
|
||||
ServerHttpRequest mutatedRequest = request.mutate()
|
||||
.headers(httpHeaders -> {
|
||||
httpHeaders.set(USER_HEADER, ssoUserFinal);
|
||||
})
|
||||
.headers(h -> h.add(USER_HEADER, ssoUser))
|
||||
.build();
|
||||
ServerWebExchange mutatedExchange = exchange.mutate()
|
||||
.request(mutatedRequest)
|
||||
.build();
|
||||
return chain.filter(mutatedExchange);
|
||||
return chain.filter(exchange.mutate().request(mutatedRequest).build());
|
||||
}
|
||||
|
||||
// 独立场景:根据 DEEPDATA_JWT_ENABLE 决定是否要求登录
|
||||
// 3. 如果未开启 JWT 验证,直接放行
|
||||
if (!jwtEnable) {
|
||||
log.debug("JWT authentication is not required, passing request without user header");
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// JWT 模式:必须登录,验证 Token
|
||||
// ==========================================
|
||||
// 🆕 4. 核心修改:多种方式获取 Token
|
||||
// ==========================================
|
||||
String token = null;
|
||||
|
||||
// 方式 A: 从 Header 获取 (标准 API 请求)
|
||||
String authHeader = request.getHeaders().getFirst(AUTH_HEADER);
|
||||
if (authHeader == null || !authHeader.startsWith(TOKEN_PREFIX)) {
|
||||
log.warn("JWT authentication is required but no valid Authorization header found");
|
||||
if (authHeader != null && authHeader.startsWith(TOKEN_PREFIX)) {
|
||||
token = authHeader.substring(TOKEN_PREFIX.length());
|
||||
log.debug("Token found in Authorization header");
|
||||
}
|
||||
|
||||
// 方式 B: 从 Query 参数获取 (解决 <a href> / 预览 401)
|
||||
if (StringUtils.isBlank(token)) {
|
||||
String tokenFromQuery = request.getQueryParams().getFirst("token");
|
||||
if (StringUtils.isNotBlank(tokenFromQuery)) {
|
||||
token = tokenFromQuery;
|
||||
log.debug("Token found in Query parameters: ?token=...");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 如果两种方式都没拿到 Token,拦截并返回 401
|
||||
if (StringUtils.isBlank(token)) {
|
||||
log.warn("JWT required but no token found in header or query params");
|
||||
return sendUnauthorizedResponse(exchange);
|
||||
}
|
||||
|
||||
String token = authHeader.substring(TOKEN_PREFIX.length());
|
||||
// 6. 验证 Token 有效性
|
||||
String user = userService.validateToken(token);
|
||||
if (StringUtils.isBlank(user)) {
|
||||
log.warn("JWT token validation failed");
|
||||
log.warn("JWT token validation failed for token starting with: {}",
|
||||
token.length() > 10 ? token.substring(0, 10) : token);
|
||||
return sendUnauthorizedResponse(exchange);
|
||||
}
|
||||
|
||||
log.info("JWT mode authenticated, adding User header: {}", user);
|
||||
log.info("JWT authenticated user: {}", user);
|
||||
// 注入 User Header 给下游服务
|
||||
ServerHttpRequest mutatedRequest = request.mutate()
|
||||
.headers(httpHeaders -> {
|
||||
httpHeaders.set(USER_HEADER, user);
|
||||
})
|
||||
.headers(h -> h.add(USER_HEADER, user))
|
||||
.build();
|
||||
ServerWebExchange mutatedExchange = exchange.mutate()
|
||||
.request(mutatedRequest)
|
||||
.build();
|
||||
return chain.filter(mutatedExchange);
|
||||
return chain.filter(exchange.mutate().request(mutatedRequest).build());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error in AuthFilter", e);
|
||||
return sendUnauthorizedResponse(exchange);
|
||||
@@ -148,21 +122,12 @@ public class AuthFilter implements GlobalFilter, Ordered {
|
||||
try {
|
||||
bytes = objectMapper.writeValueAsString(Response.error(CommonErrorCode.UNAUTHORIZED)).getBytes(StandardCharsets.UTF_8);
|
||||
} catch (JsonProcessingException e) {
|
||||
String responseBody = "{\"code\":401,\"message\":\"登录失败:用户名或密码错误\",\"data\":null}";
|
||||
bytes = responseBody.getBytes(StandardCharsets.UTF_8);
|
||||
bytes = "{\"code\":401,\"message\":\"Unauthorized\",\"data\":null}".getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
DataBuffer buffer = response.bufferFactory().wrap(bytes);
|
||||
return response.writeWith(Mono.just(buffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户数据隔离过滤器优先级
|
||||
*
|
||||
* Order = 2,在 OmsAuthFilter (Order=1) 之后执行
|
||||
* 确保先执行 SSO 认证,再执行用户数据隔离
|
||||
*
|
||||
* @return order value (2 = after SSO authentication)
|
||||
*/
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return 2;
|
||||
|
||||
@@ -2,14 +2,12 @@ package com.deepdata.gateway.domain.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.deepdata.common.infrastructure.config.PgJsonTypeHandler;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户
|
||||
@@ -28,14 +26,6 @@ public class User {
|
||||
private String passwordHash;
|
||||
private String fullName;
|
||||
private String role;
|
||||
|
||||
/**
|
||||
* Casdoor 组列表(users.user_groups JSON 列,M2;本地注册用户为 null)。
|
||||
* 组 → 主角色解析见 {@code com.deepdata.gateway.common.config.RoleResolver}。
|
||||
*/
|
||||
@TableField(value = "user_groups", typeHandler = PgJsonTypeHandler.class)
|
||||
private List<String> userGroups;
|
||||
|
||||
private boolean enabled;
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
|
||||
+39
-48
@@ -12,7 +12,6 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.deepdata.gateway.interfaces.dto.RegisterRequest;
|
||||
@@ -45,7 +44,7 @@ public class UserService {
|
||||
LambdaQueryWrapper<User> userWrapper = new LambdaQueryWrapper<>();
|
||||
userWrapper.eq(User::getUsername, user.getUsername());
|
||||
User userInDB = userRepository.getOne(userWrapper);
|
||||
|
||||
|
||||
if (userInDB != null && validPassword(user, userInDB)) {
|
||||
String token = generateToken(userInDB);
|
||||
userInDB.setToken(token);
|
||||
@@ -59,66 +58,35 @@ public class UserService {
|
||||
return encoder.matches(user.getPassword(), userInDB.getPasswordHash());
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发本地 JWT(M2 起为 public,供 Casdoor 落地统一复用;claims 口径一致:
|
||||
* sub=username, uid, role,组驱动用户附加 groups 列表)。
|
||||
*
|
||||
* <p>安全边界说明:本地 token 为网关内部信任链(自签自验),保持 HS256;
|
||||
* Casdoor id_token 走独立 RS256 JWKS 验证链({@code CasdoorIdTokenVerifier}),
|
||||
* 两条链互不混淆。
|
||||
*/
|
||||
public String generateToken(User user) {
|
||||
private String generateToken(User user) {
|
||||
long now = System.currentTimeMillis();
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getUsername())
|
||||
.claim("uid", user.getId())
|
||||
.claim("role", user.getRole())
|
||||
.setIssuedAt(new Date(now))
|
||||
.setExpiration(new Date(now + expirationSeconds * 1000))
|
||||
.signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)));
|
||||
if (user.getUserGroups() != null && !user.getUserGroups().isEmpty()) {
|
||||
builder.claim("groups", user.getUserGroups());
|
||||
}
|
||||
return builder.compact();
|
||||
.signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验本地 JWT。M3 起额外校验用户状态:用户不存在或已禁用(本地/同步置禁)
|
||||
* 返回 null → 网关 AuthFilter 直接 401,禁用账号的存量 token 立即失效。
|
||||
*/
|
||||
public String validateToken(String token) {
|
||||
try {
|
||||
Jws<Claims> claimsJws = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))).build().parseSignedClaims(token);
|
||||
String username = claimsJws.getPayload().getSubject();
|
||||
return findUserByUsername(username)
|
||||
.filter(User::isEnabled)
|
||||
.map(User::getUsername)
|
||||
.orElse(null);
|
||||
Jws<Claims> claimsJws = Jwts.parserBuilder()
|
||||
.setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))).build()
|
||||
.parseClaimsJws(token);
|
||||
return claimsJws.getBody().getSubject();
|
||||
} catch (JwtException | IllegalArgumentException ex) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户名查本地用户(M2:/me 角色与组解析、Casdoor upsert 复用)。
|
||||
*
|
||||
* @param username 本地用户名
|
||||
* @return 命中用户;不存在 / 空参返回 empty
|
||||
*/
|
||||
public Optional<User> findUserByUsername(String username) {
|
||||
if (username == null || username.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(User::getUsername, username);
|
||||
return Optional.ofNullable(userRepository.getOne(wrapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*
|
||||
* @param registerRequest registration request containing user details
|
||||
* @return registered user with token if successful, empty if username or email already exists
|
||||
* @return registered user with token if successful, empty if username or email
|
||||
* already exists
|
||||
*/
|
||||
public Optional<User> register(RegisterRequest registerRequest) {
|
||||
// Check if username already exists
|
||||
@@ -140,22 +108,45 @@ public class UserService {
|
||||
user.setUsername(registerRequest.getUsername());
|
||||
user.setEmail(registerRequest.getEmail());
|
||||
user.setFullName(registerRequest.getUsername());
|
||||
|
||||
|
||||
// Encode password
|
||||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
|
||||
user.setPasswordHash(encoder.encode(registerRequest.getPassword()));
|
||||
|
||||
|
||||
// Set default role and enabled status
|
||||
user.setRole("USER");
|
||||
user.setEnabled(true);
|
||||
|
||||
|
||||
// Save user
|
||||
userRepository.save(user);
|
||||
|
||||
|
||||
// Generate token
|
||||
String token = generateToken(user);
|
||||
user.setToken(token);
|
||||
|
||||
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
public boolean changePassword(String username, String oldPassword, String newPassword) {
|
||||
// 1. 根据用户名查找用户
|
||||
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(User::getUsername, username);
|
||||
User user = userRepository.getOne(wrapper);
|
||||
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
|
||||
|
||||
// 2. 校验旧密码
|
||||
if (!encoder.matches(oldPassword, user.getPasswordHash())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 更新新密码
|
||||
user.setPasswordHash(encoder.encode(newPassword));
|
||||
userRepository.updateById(user);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import com.deepdata.gateway.infrastructure.casdoor.dto.CasdoorTokenResponse;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.reactive.ClientHttpRequest;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.BodyInserter;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.ClientResponse;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Casdoor OAuth2/OIDC 客户端(响应式,WebClient)。
|
||||
*
|
||||
* <p>覆盖两条网关侧流程:
|
||||
* <ul>
|
||||
* <li>{@link #exchangeCode} —— authorization_code 换 token(机密客户端,带 client_secret)</li>
|
||||
* <li>{@link #passwordGrant} —— password grant(Flow B 账密兜底)</li>
|
||||
* </ul>
|
||||
* 身份声明一律从返回的 {@code id_token} 提取(见 {@link IdTokenClaims})。
|
||||
*
|
||||
* <p>实测要点(2026-08-29,线上实例):
|
||||
* <ul>
|
||||
* <li>code 一次性,有效期约 200s,回跳后必须立即交换。</li>
|
||||
* <li>交换成功返回 200 + JSON{access_token, id_token, refresh_token, token_type, expires_in, scope};
|
||||
* 失败返回 400 + JSON{error, error_description}(如 invalid_client / invalid_grant)。</li>
|
||||
* <li>form 参数方式传 client_id/client_secret 已验证可用(无需 Basic 头)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CasdoorClient {
|
||||
|
||||
private final CasdoorProperties props;
|
||||
private final WebClient webClient;
|
||||
|
||||
public CasdoorClient(CasdoorProperties props) {
|
||||
this.props = props;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权码换 token(Flow A 回调阶段)。
|
||||
*
|
||||
* @param code Casdoor 回跳携带的一次性 code
|
||||
* @return 令牌响应(含 access_token / id_token / refresh_token)
|
||||
*/
|
||||
public Mono<CasdoorTokenResponse> exchangeCode(String code) {
|
||||
return postToken(BodyInserters.fromFormData("grant_type", "authorization_code")
|
||||
.with("code", code)
|
||||
.with("client_id", props.getClientId())
|
||||
.with("client_secret", props.getClientSecret())
|
||||
.with("redirect_uri", props.getRedirectUri()), "exchangeCode");
|
||||
}
|
||||
|
||||
/**
|
||||
* password grant(Flow B,网关侧代用户认证)。
|
||||
*
|
||||
* @param username Casdoor 用户名
|
||||
* @param password Casdoor 密码
|
||||
* @return 令牌响应(含 id_token)
|
||||
*/
|
||||
public Mono<CasdoorTokenResponse> passwordGrant(String username, String password) {
|
||||
return postToken(BodyInserters.fromFormData("grant_type", "password")
|
||||
.with("username", username)
|
||||
.with("password", password)
|
||||
.with("scope", props.getScopes())
|
||||
.with("client_id", props.getClientId())
|
||||
.with("client_secret", props.getClientSecret()), "passwordGrant");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 authorize 重定向 URL(Flow A 第一步,浏览器跳转)。
|
||||
*
|
||||
* @param state 一次性随机串(由调用方存 cookie,回调时校验并删除)
|
||||
*/
|
||||
public String buildAuthorizeUrl(String state) {
|
||||
StringBuilder sb = new StringBuilder(props.getAuthorizeUri());
|
||||
sb.append('?')
|
||||
.append("client_id=").append(enc(props.getClientId()))
|
||||
.append("&redirect_uri=").append(enc(props.getRedirectUri()))
|
||||
.append("&response_type=code")
|
||||
.append("&scope=").append(enc(props.getScopes().replace(' ', '+')))
|
||||
.append("&state=").append(enc(state));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共 token 端点调用:form 提交;2xx 解析为 {@link CasdoorTokenResponse},
|
||||
* 非 2xx 解析错误 JSON 并抛出带 error_description 的异常。
|
||||
*/
|
||||
private Mono<CasdoorTokenResponse> postToken(
|
||||
BodyInserter<?, ? super ClientHttpRequest> form, String op) {
|
||||
return webClient.post()
|
||||
.uri(props.getTokenUri())
|
||||
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
.body(form)
|
||||
.retrieve()
|
||||
.onStatus(s -> s.isError(), resp -> errorBody(resp)
|
||||
.map(b -> new CasdoorTokenException(op + " failed", b)))
|
||||
.bodyToMono(CasdoorTokenResponse.class)
|
||||
.doOnNext(r -> log.info("casdoor {} ok: tokenType={}, expiresIn={}",
|
||||
op, r.getTokenType(), r.getExpiresIn()))
|
||||
.switchIfEmpty(Mono.error(new CasdoorTokenException(
|
||||
op + " failed: empty token response", "")));
|
||||
}
|
||||
|
||||
private Mono<String> errorBody(ClientResponse resp) {
|
||||
return resp.bodyToMono(String.class).defaultIfEmpty(resp.statusCode().toString());
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** token 交换失败(invalid_client / invalid_grant / code 过期等) */
|
||||
public static class CasdoorTokenException extends RuntimeException {
|
||||
private final String detail;
|
||||
|
||||
public CasdoorTokenException(String message, String detail) {
|
||||
super(message);
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
/** Casdoor 返回的错误 JSON,形如 {"error":"invalid_grant","error_description":"..."} */
|
||||
public String getDetail() {
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
-224
@@ -1,224 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import com.deepdata.gateway.infrastructure.casdoor.dto.JwksResponse;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jws;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
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.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Casdoor id_token 验证器:拉取公开 JWKS(带 TTL 缓存),按 {@code kid} 选 RSA 公钥,
|
||||
* 用 JJWT(0.12.x)做 RS256 验签 + iss/aud/exp/nbf 校验。
|
||||
*
|
||||
* <p>实测(2026-08-30):JWKS 端点 {@code /.well-known/jwks.json} 无需鉴权,返回
|
||||
* <pre>{"keys":[{"use":"sig","kty":"RSA","kid":"cert-built-in","alg":"RS256","n":"...","e":"AQAB","x5c":["..."]}]}</pre>
|
||||
* id_token 头为 {@code {"alg":"RS256","typ":"JWT","kid":"cert-built-in"}}。
|
||||
*
|
||||
* <p>M2:验证通过后按 {@code groups-claim}(默认 groups)提取组列表,随
|
||||
* {@link IdTokenClaims#groups()} 返回,供 {@code CasdoorUserService} 落地角色。
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CasdoorIdTokenVerifier {
|
||||
|
||||
private final CasdoorProperties props;
|
||||
private final WebClient webClient;
|
||||
|
||||
/** JWKS 缓存(kid → RSAPublicKey),证书轮转时靠 TTL / kid 未命中强制刷新 */
|
||||
private volatile CacheEntry cache;
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private record CacheEntry(long fetchedAtMillis, Map<String, RSAPublicKey> keys) {}
|
||||
|
||||
public CasdoorIdTokenVerifier(CasdoorProperties props) {
|
||||
this.props = props;
|
||||
this.webClient = WebClient.builder()
|
||||
.clientConnector(new ReactorClientHttpConnector(HttpClient.create()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 id_token 并提取身份声明。
|
||||
*
|
||||
* @param idToken Casdoor 返回的 id_token(RS256)
|
||||
* @return 验证通过的 {@link IdTokenClaims}
|
||||
* @throws CasdoorIdTokenException 签名错误 / iss/aud/exp 校验失败 / JWKS 不可用
|
||||
*/
|
||||
public Mono<IdTokenClaims> verify(String idToken) {
|
||||
return parseAndVerify(idToken, false)
|
||||
.flatMap(jws -> {
|
||||
Claims body = jws.getPayload();
|
||||
IdTokenClaims claims = new IdTokenClaims(
|
||||
body.getSubject(),
|
||||
body.get("name", String.class),
|
||||
body.get("email", String.class),
|
||||
body.getExpiration() != null ? body.getExpiration().getTime() / 1000 : 0,
|
||||
body.getIssuedAt() != null ? body.getIssuedAt().getTime() / 1000 : 0,
|
||||
body,
|
||||
extractGroups(body));
|
||||
if (claims.sub() == null || claims.name() == null) {
|
||||
return Mono.error(new CasdoorIdTokenException(
|
||||
"id_token missing sub/name claims"));
|
||||
}
|
||||
return Mono.just(claims);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 {@code groups-claim} 配置提取组列表(id_token 中 JSON 数组;
|
||||
* 个别配置下可能退化为单字符串)。无 groups claim 返回 null。
|
||||
*/
|
||||
private List<String> extractGroups(Claims body) {
|
||||
Object g = body.get(props.getGroupsClaim());
|
||||
if (g instanceof List<?> list) {
|
||||
List<String> groups = new ArrayList<>(list.size());
|
||||
for (Object o : list) {
|
||||
if (o != null) {
|
||||
groups.add(String.valueOf(o));
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
if (g instanceof String s && !s.isBlank()) {
|
||||
return List.of(s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 kid 取公钥并解析验签。kid 未命中且尚未强制刷新过 → 刷新 JWKS 重试一次。
|
||||
*/
|
||||
private Mono<Jws<Claims>> parseAndVerify(String idToken, boolean forced) {
|
||||
return loadKeys(forced)
|
||||
.map(keys -> Optional.ofNullable(parseWithKid(idToken, keys)))
|
||||
.flatMap(opt -> {
|
||||
if (opt.isPresent()) {
|
||||
return Mono.just(opt.get());
|
||||
}
|
||||
if (forced) {
|
||||
return Mono.error(new CasdoorIdTokenException(
|
||||
"id_token kid not found in JWKS after refresh"));
|
||||
}
|
||||
return loadKeys(true)
|
||||
.map(k2 -> Optional.ofNullable(parseWithKid(idToken, k2)))
|
||||
.map(o2 -> o2.<CasdoorIdTokenException>orElseThrow(() ->
|
||||
new CasdoorIdTokenException(
|
||||
"id_token kid not found in JWKS after refresh")));
|
||||
});
|
||||
}
|
||||
|
||||
private Jws<Claims> parseWithKid(String idToken, Map<String, RSAPublicKey> keys) {
|
||||
String kid = headerKid(idToken);
|
||||
RSAPublicKey key = keys.get(kid);
|
||||
if (key == null) {
|
||||
log.warn("casdoor jwks: kid [{}] not cached, size={}", kid, keys.size());
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.requireIssuer(props.getIssuer())
|
||||
.requireAudience(props.getClientId())
|
||||
.build()
|
||||
.parseSignedClaims(idToken);
|
||||
} catch (Exception e) {
|
||||
throw new CasdoorIdTokenException("id_token verification failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接 base64url 解码 JWT header 段取 {@code kid}。
|
||||
* 不能用 {@code Jwts.parser().build().parse(...)}:jjwt 0.12 对已签名 JWT
|
||||
* 在未提供验签密钥时会抛 {@code UnsupportedJwtException},无法仅取 header。
|
||||
*/
|
||||
private static String headerKid(String idToken) {
|
||||
int firstDot = idToken.indexOf('.');
|
||||
if (firstDot <= 0) {
|
||||
throw new CasdoorIdTokenException("id_token missing JWS header segment");
|
||||
}
|
||||
try {
|
||||
String headerJson = new String(
|
||||
Base64.getUrlDecoder().decode(idToken.substring(0, firstDot)),
|
||||
StandardCharsets.UTF_8);
|
||||
return MAPPER.readTree(headerJson).path("kid").asText(null);
|
||||
} catch (CasdoorIdTokenException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new CasdoorIdTokenException("id_token header unparsable: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<Map<String, RSAPublicKey>> loadKeys(boolean forced) {
|
||||
CacheEntry current = cache;
|
||||
long ttlMillis = props.getJwksCacheSeconds() * 1000L;
|
||||
if (!forced && current != null
|
||||
&& System.currentTimeMillis() - current.fetchedAtMillis() < ttlMillis) {
|
||||
return Mono.just(current.keys());
|
||||
}
|
||||
return webClient.get()
|
||||
.uri(props.getJwksUri())
|
||||
.retrieve()
|
||||
.bodyToMono(JwksResponse.class)
|
||||
.map(jwks -> {
|
||||
Map<String, RSAPublicKey> keys = new HashMap<>();
|
||||
if (jwks.getKeys() != null) {
|
||||
for (JwksResponse.Jwk jwk : jwks.getKeys()) {
|
||||
if ("RSA".equals(jwk.getKty()) && jwk.getKid() != null
|
||||
&& jwk.getN() != null && jwk.getE() != null) {
|
||||
keys.put(jwk.getKid(), toRsaKey(jwk));
|
||||
}
|
||||
}
|
||||
}
|
||||
cache = new CacheEntry(System.currentTimeMillis(), keys);
|
||||
log.info("casdoor jwks refreshed: {} key(s)", keys.size());
|
||||
return keys;
|
||||
})
|
||||
.doOnError(e -> log.error("casdoor jwks fetch failed: {}", e.getMessage(), e));
|
||||
}
|
||||
|
||||
private static RSAPublicKey toRsaKey(JwksResponse.Jwk jwk) {
|
||||
BigInteger n = new BigInteger(1, Base64.getUrlDecoder().decode(jwk.getN()));
|
||||
BigInteger e = new BigInteger(1, Base64.getUrlDecoder().decode(jwk.getE()));
|
||||
try {
|
||||
return (RSAPublicKey) KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new RSAPublicKeySpec(n, e));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException ex) {
|
||||
throw new CasdoorIdTokenException("invalid JWKS RSA key: " + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** id_token 验证失败(签名/iss/aud/exp 任一不过,或 JWKS 不可用) */
|
||||
public static class CasdoorIdTokenException extends RuntimeException {
|
||||
public CasdoorIdTokenException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public CasdoorIdTokenException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Casdoor 接入配置,对应 yml 前缀 {@code deepdata.casdoor.*}。
|
||||
*
|
||||
* <p>机密项(client-secret、redirect-uri)建议经 Nacos / 环境变量注入,勿写死在仓库。
|
||||
*
|
||||
* <p>M2 新增(组驱动目录菜单体系):
|
||||
* <ul>
|
||||
* <li>{@code groups-claim} —— id_token 中组列表的 claim 名(Casdoor 应用 tokenFields 含 Groups 时携带);</li>
|
||||
* <li>{@code group-prefix} —— 角色组名前缀(组名 = 前缀 + 角色键,如 deepdata-admin);</li>
|
||||
* <li>{@code role-priority} —— 角色键 → 权重(越大越优先),用于从多组解析「主角色」。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "deepdata.casdoor")
|
||||
public class CasdoorProperties {
|
||||
|
||||
/** 总开关:false 时 SSO Controller 不注册,系统行为与接入前完全一致 */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** OIDC issuer,用于 id_token 的 iss 校验 */
|
||||
private String issuer = "https://apikey.apescale.com:1013";
|
||||
|
||||
/** 授权端点(浏览器跳转) */
|
||||
private String authorizeUri = "https://apikey.apescale.com:1013/login/oauth/authorize";
|
||||
|
||||
/** 令牌端点(code 交换 / password grant) */
|
||||
private String tokenUri = "https://apikey.apescale.com:1013/api/login/oauth/access_token";
|
||||
|
||||
/** 用户信息端点(可选,身份以 id_token 为准) */
|
||||
private String userinfoUri = "https://apikey.apescale.com:1013/api/userinfo";
|
||||
|
||||
/**
|
||||
* JWKS 端点(公开)。M2 修正:线上实例实测地址为 {@code /.well-known/jwks.json}
|
||||
* (2026-08-30 验证),与 M1 方案文档一致。
|
||||
*/
|
||||
private String jwksUri = "https://apikey.apescale.com:1013/.well-known/jwks.json";
|
||||
|
||||
private String clientId;
|
||||
private String clientSecret;
|
||||
|
||||
/** 必须与 Casdoor 应用 redirectUris 登记值完全一致 */
|
||||
private String redirectUri;
|
||||
|
||||
private String scopes = "openid profile email";
|
||||
|
||||
/** state 随机串存 cookie 的名称(HttpOnly) */
|
||||
private String stateCookieName = "casdoor_sso_state";
|
||||
|
||||
/** state 有效期(秒),应与 code 有效期(实测约 200s)对齐并留余量 */
|
||||
private int stateTtlSeconds = 300;
|
||||
|
||||
/**
|
||||
* state cookie 是否带 Secure 属性。生产(HTTPS)保持 true;
|
||||
* 本地 HTTP 联调(http://localhost:8765)必须设 false,否则浏览器不会回传该 cookie。
|
||||
*/
|
||||
private boolean stateCookieSecure = true;
|
||||
|
||||
/** 首登是否自动创建本地用户 */
|
||||
private boolean autoProvision = true;
|
||||
|
||||
/** 自动建号时的默认本地角色 */
|
||||
private String defaultRole = "USER";
|
||||
|
||||
/** Casdoor 用户名 → 本地角色 映射,未命中用 defaultRole。例: admin=ADMIN */
|
||||
private Map<String, String> usernameRoleMapping = new LinkedHashMap<>();
|
||||
|
||||
/** JWKS 缓存时长(秒);kid 未命中时强制刷新一次 */
|
||||
private long jwksCacheSeconds = 3600;
|
||||
|
||||
/** WebClient 超时(毫秒) */
|
||||
private int connectTimeoutMillis = 5_000;
|
||||
private int readTimeoutMillis = 10_000;
|
||||
|
||||
/** Flow B:/api/user/login 是否优先走 Casdoor password grant */
|
||||
private PasswordGrant passwordGrant = new PasswordGrant();
|
||||
|
||||
// ---------- M2:组驱动角色 ----------
|
||||
|
||||
/** id_token 中组列表的 claim 名(Casdoor 应用 tokenFields=["Groups"] 时携带) */
|
||||
private String groupsClaim = "groups";
|
||||
|
||||
/** 角色组名前缀:组名 = group-prefix + 角色键(如 deepdata-admin) */
|
||||
private String groupPrefix = "deepdata-";
|
||||
|
||||
/** 角色键 → 权重(越大越优先)。组名不在此表 → 不参与主角色解析 */
|
||||
private Map<String, Integer> rolePriority = defaultRolePriority();
|
||||
|
||||
private static Map<String, Integer> defaultRolePriority() {
|
||||
Map<String, Integer> m = new LinkedHashMap<>();
|
||||
m.put("admin", 4);
|
||||
m.put("dev", 3);
|
||||
m.put("annotator", 2);
|
||||
m.put("viewer", 1);
|
||||
return m;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PasswordGrant {
|
||||
private boolean enabled = false;
|
||||
/** Casdoor 认证失败时是否回落本地 BCrypt(迁移期建议 true) */
|
||||
private boolean fallbackToLocal = true;
|
||||
}
|
||||
}
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.domain.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
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.UUID;
|
||||
|
||||
/**
|
||||
* Casdoor 身份 → 本地用户 的 upsert 服务(M1 provision 的 M2 演进版)。
|
||||
*
|
||||
* <p>职责:
|
||||
* <ul>
|
||||
* <li>首次登录自动建号(auto-provision);存量用户按 Casdoor 邮箱/组刷新。</li>
|
||||
* <li>M2 组驱动角色:id_token 携带 groups 时刷新本地 {@code user_groups}
|
||||
* (users.user_groups JSON 列),角色经 {@link RoleResolver#resolveRole}
|
||||
* 解析:显式用户名映射 > 组主角色 > defaultRole。</li>
|
||||
* <li>统一签发本地 JWT:删除 M1 重复的 issueLocalJwt,直接复用
|
||||
* {@link UserService#generateToken(User)}(已改 public,同一
|
||||
* secret/expiration/claims 体系,并携带 groups claim,供网关下游鉴权)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CasdoorUserService {
|
||||
|
||||
private static final String SYSTEM_USER = "system";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final CasdoorProperties props;
|
||||
private final RoleResolver roleResolver;
|
||||
/** 网关既有用户服务:BCrypt 校验 + 统一 JWT 签发(M2 起 generateToken 为 public) */
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* 由经验证的 Casdoor 身份 upsert 本地用户并签发本地 JWT。
|
||||
*
|
||||
* @param claims 已验证的 id_token 声明(含 M2 groups)
|
||||
* @return 本地用户 + 本地 JWT + 是否新建
|
||||
*/
|
||||
public Mono<ProvisionedUser> upsertFromCasdoor(IdTokenClaims claims) {
|
||||
String username = normalizeUsername(claims.name());
|
||||
if (username == null || SYSTEM_USER.equals(username)) {
|
||||
return Mono.error(new CasdoorProvisionException(
|
||||
"invalid casdoor username: " + claims.name()));
|
||||
}
|
||||
return Mono.fromCallable(() -> {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
User user = findByUsername(username);
|
||||
if (user != null) {
|
||||
if (!user.isEnabled()) {
|
||||
throw new CasdoorProvisionException("local user disabled: " + username);
|
||||
}
|
||||
user.setLastLoginAt(now);
|
||||
if (hasText(claims.email()) && !claims.email().equals(user.getEmail())) {
|
||||
user.setEmail(claims.email());
|
||||
}
|
||||
applyGroupsAndRole(user, claims, username);
|
||||
// 存量用户走更新;IRepository.save() 恒为 INSERT,带 id 调用会撞主键
|
||||
userRepository.updateById(user);
|
||||
return new ProvisionedUser(user, userService.generateToken(user), false);
|
||||
}
|
||||
if (!props.isAutoProvision()) {
|
||||
throw new CasdoorProvisionException(
|
||||
"local user not found and auto-provision disabled: " + username);
|
||||
}
|
||||
User created = createUser(claims, username, now);
|
||||
try {
|
||||
userRepository.save(created);
|
||||
} catch (DuplicateKeyException race) {
|
||||
// 并发首登:另一请求已建号 → 复用,避免唯一键冲突
|
||||
log.info("casdoor provision race on [{}], reusing existing user", username);
|
||||
created = findByUsername(username);
|
||||
if (created == null) {
|
||||
// 冲突来自 email 唯一键(用户名不冲突)→ 显式报错,避免 NPE
|
||||
throw new CasdoorProvisionException(
|
||||
"unique-key conflict but user not found (likely email collision): " + username);
|
||||
}
|
||||
}
|
||||
log.info("casdoor auto-provisioned local user [{}] role={} groups={}",
|
||||
username, created.getRole(), created.getUserGroups());
|
||||
return new ProvisionedUser(created, userService.generateToken(created), true);
|
||||
}).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/**
|
||||
* M2:groups claim 非空 → 刷新本地 user_groups;角色统一经
|
||||
* {@code RoleResolver.resolveRole}(映射 > 主角色 > defaultRole)。
|
||||
*/
|
||||
private void applyGroupsAndRole(User user, IdTokenClaims claims, String username) {
|
||||
if (claims.groups() != null) {
|
||||
user.setUserGroups(claims.groups());
|
||||
}
|
||||
user.setRole(roleResolver.resolveRole(username, claims.groups()));
|
||||
}
|
||||
|
||||
private User findByUsername(String username) {
|
||||
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(User::getUsername, username);
|
||||
return userRepository.getOne(wrapper);
|
||||
}
|
||||
|
||||
private User createUser(IdTokenClaims claims, String username, LocalDateTime now) {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
// email 列 NOT NULL UNIQUE:空串会互相冲突,缺省按用户名合成占位 email
|
||||
String email = claims.email();
|
||||
user.setEmail(email == null || email.isBlank() ? username + "@casdoor.local" : email.trim());
|
||||
user.setFullName(claims.name());
|
||||
// 本地密码不可用:随机占位(登录统一走 Casdoor,本地 BCrypt 仅迁移期兜底)
|
||||
user.setPasswordHash(new BCryptPasswordEncoder(10).encode(UUID.randomUUID().toString()));
|
||||
user.setEnabled(true);
|
||||
user.setLastLoginAt(now);
|
||||
applyGroupsAndRole(user, claims, username);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Casdoor 用户名 → 本地用户名:trim + 非法字符替换为 _ + 截断 64。
|
||||
* 与 Casdoor 应用 userName 规则保持一致,避免本地账号漂移。
|
||||
*/
|
||||
static String normalizeUsername(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String s = raw.trim();
|
||||
if (s.length() > 64) {
|
||||
s = s.substring(0, 64);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private static boolean hasText(String s) {
|
||||
return s != null && !s.isBlank();
|
||||
}
|
||||
|
||||
/** upsert 结果:本地用户 + 本地 JWT(携带角色/组 claims)+ 是否新建 */
|
||||
public record ProvisionedUser(User user, String localToken, boolean created) {}
|
||||
|
||||
/** 预置失败:用户名非法 / 本地用户禁用 / 未开启自动建号 */
|
||||
public static class CasdoorProvisionException extends RuntimeException {
|
||||
public CasdoorProvisionException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 经验证的 Casdoor id_token 身份声明。
|
||||
*
|
||||
* <p>身份源统一取 id_token(实测:code 流程的 access_token 只含 iss/sub/aud/exp/iat,
|
||||
* 而两条流程的 id_token 都稳定携带 sub/name/email)。
|
||||
*
|
||||
* @param sub Casdoor 用户 UUID(稳定外部 ID,建议落库为 casdoor_sub)
|
||||
* @param name Casdoor 用户名(= 登录名)
|
||||
* @param email 邮箱
|
||||
* @param exp 过期时间(秒)
|
||||
* @param iat 签发时间(秒)
|
||||
* @param claims 完整声明(调试/扩展用)
|
||||
* @param groups M2:组列表(app.tokenFields=["Groups"] 时由 groups claim 提取,
|
||||
* {@code groups-claim} 可配置;无组时可能为 null)
|
||||
*/
|
||||
public record IdTokenClaims(
|
||||
String sub,
|
||||
String name,
|
||||
String email,
|
||||
long exp,
|
||||
long iat,
|
||||
Map<String, Object> claims,
|
||||
List<String> groups) {
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Casdoor token 端点响应(code 交换 / password grant 通用)。
|
||||
*
|
||||
* <p>实测返回(2026-08-29):
|
||||
* <pre>{"access_token":"...","id_token":"...","refresh_token":"...",
|
||||
* "token_type":"Bearer","expires_in":3600,"scope":"openid"}</pre>
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class CasdoorTokenResponse {
|
||||
|
||||
@JsonProperty("access_token")
|
||||
private String accessToken;
|
||||
|
||||
/** OIDC 身份源:RS256 签名,含 sub / name / email,kid=cert-built-in */
|
||||
@JsonProperty("id_token")
|
||||
private String idToken;
|
||||
|
||||
@JsonProperty("refresh_token")
|
||||
private String refreshToken;
|
||||
|
||||
@JsonProperty("token_type")
|
||||
private String tokenType;
|
||||
|
||||
@JsonProperty("expires_in")
|
||||
private Long expiresIn;
|
||||
|
||||
private String scope;
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package com.deepdata.gateway.infrastructure.casdoor.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JWKS 响应({@code GET /.well-known/jwks.json},公开无鉴权)。
|
||||
*
|
||||
* <p>实测结构(2026-08-30,M2 修正为 .json 端点):
|
||||
* <pre>{"keys":[{"use":"sig","kty":"RSA","kid":"cert-built-in","alg":"RS256",
|
||||
* "n":"...base64url...","e":"AQAB","x5c":["...PEM base64..."]}]}</pre>
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class JwksResponse {
|
||||
|
||||
private List<Jwk> keys;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Jwk {
|
||||
private String use;
|
||||
private String kty;
|
||||
private String kid;
|
||||
private String alg;
|
||||
/** RSA 模数,base64url(无 padding) */
|
||||
private String n;
|
||||
/** RSA 公钥指数,base64url(常见 "AQAB" = 65537) */
|
||||
private String e;
|
||||
/** X.509 证书链(base64 DER),可选 */
|
||||
private List<String> x5c;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.deepdata.gateway.interfaces.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@Data
|
||||
public class ChangePasswordRequest {
|
||||
@NotBlank(message = "旧密码不能为空")
|
||||
private String oldPassword;
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Size(min = 6, message = "密码长度不能少于6位")
|
||||
private String newPassword;
|
||||
}
|
||||
-13
@@ -5,8 +5,6 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户信息响应
|
||||
*
|
||||
@@ -49,15 +47,4 @@ public class UserResponse {
|
||||
* 是否强制要求登录(由 deepdata.jwt.enable 控制)
|
||||
*/
|
||||
private Boolean requireLogin;
|
||||
|
||||
/**
|
||||
* 主角色(M2 组驱动目录菜单体系):Casdoor 组解析结果 / 存量 users.role=ADMIN
|
||||
* 兼容映射;null 时前端按 viewer-minimal 兜底展示,不 403
|
||||
*/
|
||||
private String role;
|
||||
|
||||
/**
|
||||
* Casdoor 组列表(users.user_groups JSON 列,M2),本地注册用户为 null
|
||||
*/
|
||||
private List<String> groups;
|
||||
}
|
||||
|
||||
-218
@@ -1,218 +0,0 @@
|
||||
package com.deepdata.gateway.interfaces.rest;
|
||||
|
||||
import com.deepdata.common.infrastructure.common.IgnoreResponseWrap;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorClient;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorIdTokenVerifier;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorProperties;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorUserService;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.IdTokenClaims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpCookie;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Casdoor OIDC 端点(Flow A:授权码 SSO,浏览器入口)。
|
||||
*
|
||||
* <p>端点(需加入 {@code AuthFilter} 公开放行清单,见 integration/README.md):
|
||||
* <ul>
|
||||
* <li>{@code GET /api/user/oauth/login?next=/dashboard}
|
||||
* —— 生成一次性 state(存 HttpOnly cookie)→ 302 到 Casdoor authorize</li>
|
||||
* <li>{@code GET /api/user/oauth/callback?code&state}
|
||||
* —— 校验 state → 换 token → JWKS 验签 id_token → 落地本地用户 →
|
||||
* 签本地 HS256 JWT → 302 回前端 {@code next?sso_token=...}</li>
|
||||
* <li>{@code GET /api/user/oauth/logout?next=/}
|
||||
* —— 302 回前端 {@code next?sso_logout=1}(本地 token 存 localStorage,由前端清除)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>CSRF:state 为一次性随机串,与 next 一起存 HttpOnly cookie(TTL 默认 300s,
|
||||
* 覆盖 code 有效期实测约 200s 并留余量),回调恒定时间比较后删除。
|
||||
*
|
||||
* <p>M2(组驱动目录菜单体系):回调落地走 {@link CasdoorUserService#upsertFromCasdoor},
|
||||
* id_token 的 groups claim 刷新本地 {@code user_groups},角色按
|
||||
* 映射 > 组主角色 > defaultRole 解析(见 {@code RoleResolver}),本地 JWT
|
||||
* 统一由 {@code UserService.generateToken} 签发(public,携带 groups claim)。
|
||||
*
|
||||
* <p>仅当 {@code deepdata.casdoor.enabled=true} 时注册;关闭时行为与接入前完全一致
|
||||
* (配置开关见 {@code /sso/casdoor/config})。
|
||||
*
|
||||
* @since 2026/8/29
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/user/oauth")
|
||||
@RequiredArgsConstructor
|
||||
@IgnoreResponseWrap
|
||||
@ConditionalOnProperty(prefix = "deepdata.casdoor", name = "enabled", havingValue = "true")
|
||||
public class CasdoorOauthController {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64URL_DEC = Base64.getUrlDecoder();
|
||||
|
||||
private final CasdoorProperties props;
|
||||
private final CasdoorClient casdoorClient;
|
||||
private final CasdoorIdTokenVerifier idTokenVerifier;
|
||||
private final CasdoorUserService casdoorUserService;
|
||||
|
||||
/** SSO 入口:浏览器跳到这里发起 Casdoor 登录 */
|
||||
@GetMapping("/login")
|
||||
public Mono<ResponseEntity<Void>> login(@RequestParam(name = "next", required = false) String next,
|
||||
ServerWebExchange exchange) {
|
||||
String safeNext = sanitizeNext(next);
|
||||
String state = B64URL.encodeToString(randomBytes(32));
|
||||
String cookieValue = state + "." + B64URL.encodeToString(safeNext.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
exchange.getResponse().addCookie(
|
||||
stateCookie(cookieValue, Duration.ofSeconds(props.getStateTtlSeconds())));
|
||||
String authorizeUrl = casdoorClient.buildAuthorizeUrl(state);
|
||||
log.info("casdoor sso login: next={}", safeNext);
|
||||
return Mono.just(redirect(URI.create(authorizeUrl)));
|
||||
}
|
||||
|
||||
/** Casdoor 回跳:code 换 token → 验签 → 落地(M2: 组驱动角色)→ 302 回前端带 sso_token */
|
||||
@GetMapping("/callback")
|
||||
public Mono<ResponseEntity<Void>> callback(@RequestParam(name = "code", required = false) String code,
|
||||
@RequestParam(name = "state", required = false) String state,
|
||||
ServerWebExchange exchange) {
|
||||
String cookieValue = readCookie(exchange, props.getStateCookieName());
|
||||
// state cookie 一次性:无论成败,先删
|
||||
exchange.getResponse().addCookie(stateCookie(null, Duration.ZERO));
|
||||
|
||||
String safeNext;
|
||||
if (cookieValue == null || !cookieValue.contains(".") || state == null) {
|
||||
log.warn("casdoor sso callback: missing state cookie or state param");
|
||||
return errorRedirect(null, "state_missing");
|
||||
}
|
||||
int dot = cookieValue.indexOf('.');
|
||||
String expectedState = cookieValue.substring(0, dot);
|
||||
safeNext = sanitizeNext(decodeNext(cookieValue.substring(dot + 1)));
|
||||
|
||||
if (!MessageDigest.isEqual(
|
||||
expectedState.getBytes(StandardCharsets.UTF_8),
|
||||
state.getBytes(StandardCharsets.UTF_8))) {
|
||||
log.warn("casdoor sso callback: state mismatch (csrf guard tripped)");
|
||||
return errorRedirect(safeNext, "state_mismatch");
|
||||
}
|
||||
if (code == null || code.isBlank()) {
|
||||
return errorRedirect(safeNext, "code_missing");
|
||||
}
|
||||
|
||||
return casdoorClient.exchangeCode(code)
|
||||
.flatMap(resp -> {
|
||||
if (resp.getIdToken() == null || resp.getIdToken().isBlank()) {
|
||||
return Mono.error(new CasdoorIdTokenVerifier.CasdoorIdTokenException(
|
||||
"token response without id_token"));
|
||||
}
|
||||
return idTokenVerifier.verify(resp.getIdToken());
|
||||
})
|
||||
.flatMap(claims -> casdoorUserService.upsertFromCasdoor(claims))
|
||||
.map(provisioned -> {
|
||||
log.info("casdoor sso ok: username={}, role={}, created={}",
|
||||
provisioned.user().getUsername(),
|
||||
provisioned.user().getRole(),
|
||||
provisioned.created());
|
||||
return redirect(appendParam(safeNext, "sso_token", provisioned.localToken()));
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
log.error("casdoor sso callback failed: {}", e.getMessage(), e);
|
||||
return errorRedirect(safeNext, classifyError(e));
|
||||
});
|
||||
}
|
||||
|
||||
/** 退出:本地 token 在浏览器 localStorage,由前端清;这里只做回跳标记 */
|
||||
@GetMapping("/logout")
|
||||
public Mono<ResponseEntity<Void>> logout(@RequestParam(name = "next", required = false) String next) {
|
||||
return Mono.just(redirect(appendParam(sanitizeNext(next), "sso_logout", "1")));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private Mono<ResponseEntity<Void>> errorRedirect(String next, String reason) {
|
||||
return Mono.just(redirect(appendParam(sanitizeNext(next), "sso_error", reason)));
|
||||
}
|
||||
|
||||
private static String classifyError(Throwable e) {
|
||||
if (e instanceof CasdoorClient.CasdoorTokenException) {
|
||||
return "token_exchange";
|
||||
}
|
||||
if (e instanceof CasdoorIdTokenVerifier.CasdoorIdTokenException) {
|
||||
return "id_token_verify";
|
||||
}
|
||||
if (e instanceof CasdoorUserService.CasdoorProvisionException) {
|
||||
return "provision";
|
||||
}
|
||||
return "internal";
|
||||
}
|
||||
|
||||
private static ResponseEntity<Void> redirect(URI location) {
|
||||
return ResponseEntity.status(HttpStatus.FOUND).location(location).build();
|
||||
}
|
||||
|
||||
/** next 仅允许同源相对路径(/ 开头且非 //),防开放重定向 */
|
||||
static String sanitizeNext(String next) {
|
||||
if (next == null || next.isBlank()) {
|
||||
return "/";
|
||||
}
|
||||
if (!next.startsWith("/") || next.startsWith("//")
|
||||
|| next.length() > 1024
|
||||
|| next.indexOf('\t') >= 0 || next.indexOf('\n') >= 0) {
|
||||
return "/";
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
static URI appendParam(String next, String name, String value) {
|
||||
String sep = next.contains("?") ? "&" : "?";
|
||||
return URI.create(next + sep + name + "=" + URLEncoder.encode(value, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String decodeNext(String encoded) {
|
||||
try {
|
||||
return new String(B64URL_DEC.decode(encoded), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int n) {
|
||||
byte[] b = new byte[n];
|
||||
RANDOM.nextBytes(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
private String readCookie(ServerWebExchange exchange, String name) {
|
||||
HttpCookie cookie = exchange.getRequest().getCookies().getFirst(name);
|
||||
return cookie == null ? null : cookie.getValue();
|
||||
}
|
||||
|
||||
private ResponseCookie stateCookie(String value, Duration maxAge) {
|
||||
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(props.getStateCookieName());
|
||||
if (value != null) {
|
||||
builder.value(value);
|
||||
}
|
||||
return builder
|
||||
.path("/")
|
||||
.maxAge(maxAge)
|
||||
.httpOnly(true)
|
||||
.secure(props.isStateCookieSecure())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
package com.deepdata.gateway.interfaces.rest;
|
||||
|
||||
import com.deepdata.common.infrastructure.common.Response;
|
||||
import com.deepdata.gateway.infrastructure.casdoor.CasdoorProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Casdoor SSO 前端配置端点(M2)。
|
||||
*
|
||||
* <p>前端(登录页 / 菜单壳)通过 {@code GET /sso/casdoor/config} 获取 SSO 开关与登录/登出入口,
|
||||
* 无需感知后端细节。始终注册(不走 {@code @ConditionalOnProperty}):enabled=false 时
|
||||
* {@code enabled:false},前端自然隐藏 SSO 入口 —— 这是与 OauthController
|
||||
* 「关了就 404」的差异点,接入文档已注明。
|
||||
*
|
||||
* <p>敏感字段(clientSecret 等)绝不返回给浏览器。
|
||||
*
|
||||
* @since 2026/8/30
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/sso/casdoor")
|
||||
@RequiredArgsConstructor
|
||||
public class CasdoorSsoConfigController {
|
||||
|
||||
private final CasdoorProperties props;
|
||||
|
||||
@GetMapping("/config")
|
||||
public Response<Map<String, Object>> config() {
|
||||
Map<String, Object> cfg = new LinkedHashMap<>();
|
||||
cfg.put("enabled", props.isEnabled());
|
||||
cfg.put("loginUrl", "/api/user/oauth/login");
|
||||
cfg.put("logoutUrl", "/api/user/oauth/logout");
|
||||
cfg.put("issuer", props.getIssuer());
|
||||
cfg.put("clientId", props.getClientId());
|
||||
return Response.ok(cfg);
|
||||
}
|
||||
}
|
||||
+40
-33
@@ -4,7 +4,6 @@ import com.deepdata.common.infrastructure.common.IgnoreResponseWrap;
|
||||
import com.deepdata.common.infrastructure.common.Response;
|
||||
import com.deepdata.common.infrastructure.exception.CommonErrorCode;
|
||||
import com.deepdata.gateway.application.UserApplicationService;
|
||||
import com.deepdata.gateway.common.config.RoleResolver;
|
||||
import com.deepdata.gateway.domain.service.UserService;
|
||||
import com.deepdata.gateway.infrastructure.client.OmsExtensionService;
|
||||
import com.deepdata.gateway.infrastructure.client.OmsService;
|
||||
@@ -15,6 +14,9 @@ import com.deepdata.gateway.interfaces.dto.UserResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.deepdata.gateway.interfaces.dto.ChangePasswordRequest;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -44,8 +46,6 @@ public class UserController {
|
||||
private final UserService userService;
|
||||
private final OmsService omsService;
|
||||
private final OmsExtensionService omsExtensionService;
|
||||
/** M2:Casdoor 组 → 主角色 解析 */
|
||||
private final RoleResolver roleResolver;
|
||||
|
||||
@Value("${deepdata.jwt.enable:false}")
|
||||
private Boolean jwtEnable;
|
||||
@@ -146,7 +146,13 @@ public class UserController {
|
||||
log.warn("Failed to get user group ID: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return Response.ok(authenticatedResponse(username, "SSO"));
|
||||
return Response.ok(UserResponse.builder()
|
||||
.username(username)
|
||||
.groupId(groupId)
|
||||
.authenticated(true)
|
||||
.authMode("SSO")
|
||||
.requireLogin(true) // SSO 模式始终要求登录
|
||||
.build());
|
||||
} else {
|
||||
log.warn("OMS service returned null username");
|
||||
}
|
||||
@@ -163,7 +169,12 @@ public class UserController {
|
||||
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
log.info("JWT authentication successful: user={}", username);
|
||||
return Response.ok(authenticatedResponse(username, "JWT"));
|
||||
return Response.ok(UserResponse.builder()
|
||||
.username(username)
|
||||
.authenticated(true)
|
||||
.authMode("JWT")
|
||||
.requireLogin(true) // 已登录
|
||||
.build());
|
||||
} else {
|
||||
log.warn("JWT token validation failed");
|
||||
}
|
||||
@@ -176,39 +187,35 @@ public class UserController {
|
||||
return Response.ok(UserResponse.builder()
|
||||
.authenticated(false)
|
||||
.authMode("NONE")
|
||||
.requireLogin(requireLogin) // 关键字段:告诉前端是否需要登录
|
||||
.requireLogin(requireLogin) // 关键字段:告诉前端是否需要登录
|
||||
.build());
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装已认证响应(M2 组驱动目录菜单体系):本地库查用户 → 主角色 + 组列表。
|
||||
*
|
||||
* <p>角色口径(与 Casdoor 落地端一致):
|
||||
* <ul>
|
||||
* <li>优先 {@link RoleResolver#primaryRole}:Casdoor 组按 rolePriority
|
||||
* 取权重最高者(如 deepdata-admin → admin);</li>
|
||||
* <li>存量兼容:本地 users.role=ADMIN(大小写不敏感)→ "admin";其余值不映射,
|
||||
* 前端按 viewer-minimal 兜底展示(不 403);</li>
|
||||
* <li>groups 原样带回,供前端精确渲染组内功能。</li>
|
||||
* </ul>
|
||||
* 修改密码接口
|
||||
* 🔴 修改:使用 @PutMapping 以匹配前端请求
|
||||
*/
|
||||
private UserResponse authenticatedResponse(String username, String authMode) {
|
||||
UserResponse.UserResponseBuilder b = UserResponse.builder()
|
||||
.username(username)
|
||||
.authenticated(true)
|
||||
.authMode(authMode)
|
||||
.requireLogin(true);
|
||||
userService.findUserByUsername(username).ifPresent(u -> {
|
||||
if (u.getUserGroups() != null) {
|
||||
b.groups(u.getUserGroups());
|
||||
@PutMapping("/change-password")
|
||||
public Response<String> changePassword(
|
||||
@Valid @RequestBody ChangePasswordRequest request,
|
||||
ServerHttpRequest httpRequest) {
|
||||
String authHeader = httpRequest.getHeaders().getFirst("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
// 从 Token 中解析出当前用户名
|
||||
String username = userService.validateToken(token);
|
||||
|
||||
if (StringUtils.isNotBlank(username)) {
|
||||
// 调用业务逻辑
|
||||
boolean success = userApplicationService.changePassword(username, request);
|
||||
if (success) {
|
||||
return Response.ok("success");
|
||||
} else {
|
||||
// 旧密码错误返回 401 状态码
|
||||
return Response.error(CommonErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
String role = roleResolver.primaryRole(u.getUserGroups());
|
||||
if (role == null && StringUtils.isNotBlank(u.getRole())
|
||||
&& "ADMIN".equalsIgnoreCase(u.getRole())) {
|
||||
role = "admin";
|
||||
}
|
||||
b.role(role);
|
||||
});
|
||||
return b.build();
|
||||
}
|
||||
return Response.error(CommonErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ spring:
|
||||
deepdata:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration-seconds: 3600
|
||||
expiration-seconds: 604800
|
||||
oms:
|
||||
auth:
|
||||
enabled: ${OMS_AUTH_ENABLED:false}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
deepdata:
|
||||
DeepData:
|
||||
datamanagement:
|
||||
file-storage:
|
||||
upload-dir: ${FILE_UPLOAD_DIR:./uploads}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<artifactId>jakarta.persistence-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 共享库依赖 -->
|
||||
<!-- 共享库��-->
|
||||
<dependency>
|
||||
<groupId>com.deepdata</groupId>
|
||||
<artifactId>domain-common</artifactId>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.deepdata</groupId>
|
||||
<artifactId>services</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../services/pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>security-common</artifactId>
|
||||
<name>Security Common</name>
|
||||
<description>安全通用组件</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.deepdata</groupId>
|
||||
<artifactId>services</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../../services/pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>security-common</artifactId>
|
||||
<name>Security Common</name>
|
||||
<description>安全通用组件</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+13
-13
@@ -37,11 +37,11 @@ public class JwtUtils {
|
||||
tokenClaims.put("sub", username);
|
||||
|
||||
return Jwts.builder()
|
||||
.claims(tokenClaims)
|
||||
.subject(username)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expiration * 1000))
|
||||
.signWith(getSigningKey())
|
||||
.setClaims(tokenClaims)
|
||||
.setSubject(username)
|
||||
.setIssuedAt(new Date())
|
||||
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
|
||||
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@@ -63,11 +63,11 @@ public class JwtUtils {
|
||||
* 从令牌中获取声明
|
||||
*/
|
||||
public Claims getClaimsFromToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
return Jwts.parserBuilder()
|
||||
.setSigningKey(getSigningKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,12 +95,12 @@ public class JwtUtils {
|
||||
*/
|
||||
public String refreshToken(String token) {
|
||||
Claims claims = getClaimsFromToken(token);
|
||||
claims.setIssuedAt(new Date());
|
||||
claims.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000));
|
||||
|
||||
return Jwts.builder()
|
||||
.claims(claims)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expiration * 1000))
|
||||
.signWith(getSigningKey())
|
||||
.setClaims(claims)
|
||||
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
|
||||
.compact();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user