From edfff6abc708ddaa6e172af887e39e8d356571b0 Mon Sep 17 00:00:00 2001 From: vsxd Date: Wed, 11 Mar 2026 23:35:47 +0800 Subject: [PATCH 1/3] feat: complete Chunk 1 - backend skeleton and infrastructure - Migrate all Maven groupId and Java packages to com.iflytek.skillhub - Add Docker Compose with PostgreSQL 16, Redis 7, MinIO - Add Flyway V1 migration with Phase 1 core schema (user, auth, RBAC, namespace, audit) - Add RequestIdFilter with MDC tracing and X-Request-Id header - Add GlobalExceptionHandler and ErrorResponse DTO - Add HealthController (/api/v1/health) and OpenAPI config - Add basic SecurityConfig permitting public endpoints - Add H2 test profile for CI-friendly testing without external services - Add top-level Makefile for dev workflow orchestration - All 3 tests passing --- Makefile | 29 ++++ docker-compose.yml | 47 +++++ server/pom.xml | 12 +- server/skillhub-app/pom.xml | 13 +- .../skillhub/SkillhubApplication.java | 2 +- .../skillhub/config/OpenApiConfig.java | 25 +++ .../skillhub/config/SecurityConfig.java | 22 +++ .../skillhub/controller/HealthController.java | 17 ++ .../iflytek/skillhub/dto/ErrorResponse.java | 7 + .../exception/GlobalExceptionHandler.java | 27 +++ .../skillhub/filter/RequestIdFilter.java | 40 +++++ .../src/main/resources/application-local.yml | 2 +- .../db/migration/V1__init_schema.sql | 164 ++++++++++++++++++ .../controller/HealthControllerTest.java | 28 +++ .../skillhub/filter/RequestIdFilterTest.java | 37 ++++ .../src/test/resources/application-test.yml | 20 +++ server/skillhub-auth/pom.xml | 4 +- server/skillhub-domain/pom.xml | 2 +- server/skillhub-infra/pom.xml | 4 +- server/skillhub-search/pom.xml | 4 +- server/skillhub-storage/pom.xml | 2 +- 21 files changed, 488 insertions(+), 20 deletions(-) create mode 100644 Makefile create mode 100644 docker-compose.yml rename server/skillhub-app/src/main/java/com/{ => iflytek}/skillhub/SkillhubApplication.java (90%) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java create mode 100644 server/skillhub-app/src/test/resources/application-test.yml diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..619ded06 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: dev dev-down build test clean + +# 启动本地开发环境(仅依赖服务) +dev: + docker compose up -d + @echo "Waiting for services to be healthy..." + @sleep 5 + @echo "Services ready. Start backend with: cd server && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local" + +# 停止本地开发环境 +dev-down: + docker compose down + +# 构建后端 +build: + cd server && ./mvnw clean package -DskipTests + +# 运行测试 +test: + cd server && ./mvnw test + +# 清理构建产物 +clean: + cd server && ./mvnw clean + docker compose down -v + +# 生成 OpenAPI 类型(前端用,Phase 1 暂不实现) +generate-api: + @echo "Frontend not yet implemented" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..63b0e8e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: skillhub + POSTGRES_USER: skillhub + POSTGRES_PASSWORD: skillhub_dev + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skillhub"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + minio: + image: minio/minio:latest + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + minio_data: diff --git a/server/pom.xml b/server/pom.xml index aa44ca3f..6a167777 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -12,7 +12,7 @@ - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT pom @@ -36,27 +36,27 @@ - com.skillhub + com.iflytek.skillhub skillhub-domain ${project.version} - com.skillhub + com.iflytek.skillhub skillhub-auth ${project.version} - com.skillhub + com.iflytek.skillhub skillhub-search ${project.version} - com.skillhub + com.iflytek.skillhub skillhub-storage ${project.version} - com.skillhub + com.iflytek.skillhub skillhub-infra ${project.version} diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index f80badea..2cd5a376 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -6,7 +6,7 @@ 4.0.0 - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT @@ -28,15 +28,15 @@ 2.3.0 - com.skillhub + com.iflytek.skillhub skillhub-domain - com.skillhub + com.iflytek.skillhub skillhub-auth - com.skillhub + com.iflytek.skillhub skillhub-infra @@ -70,6 +70,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + diff --git a/server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java similarity index 90% rename from server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java rename to server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java index 6c5c5d38..b85d9caa 100644 --- a/server/skillhub-app/src/main/java/com/skillhub/SkillhubApplication.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/SkillhubApplication.java @@ -1,4 +1,4 @@ -package com.skillhub; +package com.iflytek.skillhub; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java new file mode 100644 index 00000000..e45f3c7f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/OpenApiConfig.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI skillhubOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("SkillHub API") + .description("Skills Registry Platform") + .version("0.1.0")) + .servers(List.of( + new Server().url("http://localhost:8080").description("Local development") + )); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java new file mode 100644 index 00000000..29b14f7e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/health", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + ); + return http.build(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java new file mode 100644 index 00000000..b1225f8c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/HealthController.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1") +public class HealthController { + + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java new file mode 100644 index 00000000..194b6861 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ErrorResponse.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +public record ErrorResponse( + int status, + String error, + String message +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..a8962949 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.exception; + +import com.iflytek.skillhub.dto.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGlobalException(Exception ex, WebRequest request) { + logger.error("Unhandled exception", ex); + ErrorResponse error = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Internal server error", + ex.getMessage() + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java new file mode 100644 index 00000000..e9dc6422 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class RequestIdFilter extends OncePerRequestFilter { + + private static final String REQUEST_ID_HEADER = "X-Request-Id"; + private static final String REQUEST_ID_MDC_KEY = "requestId"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String requestId = request.getHeader(REQUEST_ID_HEADER); + if (requestId == null || requestId.isBlank()) { + requestId = UUID.randomUUID().toString(); + } + + MDC.put(REQUEST_ID_MDC_KEY, requestId); + response.setHeader(REQUEST_ID_HEADER, requestId); + + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(REQUEST_ID_MDC_KEY); + } + } +} diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 4cdcbe15..899183dc 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -16,5 +16,5 @@ spring: logging: level: - com.skillhub: DEBUG + com.iflytek.skillhub: DEBUG org.springframework.security: DEBUG diff --git a/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql b/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 00000000..86b4d9b4 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,164 @@ +-- Phase 1 核心表:认证与授权 + +-- 用户账号表 +CREATE TABLE user_account ( + id BIGSERIAL PRIMARY KEY, + display_name VARCHAR(128) NOT NULL, + email VARCHAR(256), + avatar_url VARCHAR(512), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + merged_to_user_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_user_account_email ON user_account(email); +CREATE INDEX idx_user_account_status ON user_account(status); + +-- OAuth 身份绑定表 +CREATE TABLE identity_binding ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_account(id), + provider_code VARCHAR(64) NOT NULL, + subject VARCHAR(256) NOT NULL, + login_name VARCHAR(128), + extra_json JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(provider_code, subject) +); + +CREATE INDEX idx_identity_binding_user_id ON identity_binding(user_id); + +-- API Token 表 +CREATE TABLE api_token ( + id BIGSERIAL PRIMARY KEY, + subject_type VARCHAR(32) NOT NULL DEFAULT 'USER', + subject_id BIGINT NOT NULL, + user_id BIGINT NOT NULL REFERENCES user_account(id), + name VARCHAR(128) NOT NULL, + token_prefix VARCHAR(16) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + scope_json JSONB NOT NULL, + expires_at TIMESTAMP, + last_used_at TIMESTAMP, + revoked_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_api_token_user_id ON api_token(user_id); +CREATE INDEX idx_api_token_hash ON api_token(token_hash); + +-- 角色表 +CREATE TABLE role ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + description VARCHAR(512), + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 权限表 +CREATE TABLE permission ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + group_code VARCHAR(64) +); + +-- 角色权限关联表 +CREATE TABLE role_permission ( + role_id BIGINT NOT NULL REFERENCES role(id), + permission_id BIGINT NOT NULL REFERENCES permission(id), + PRIMARY KEY (role_id, permission_id) +); + +-- 用户角色绑定表 +CREATE TABLE user_role_binding ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES user_account(id), + role_id BIGINT NOT NULL REFERENCES role(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, role_id) +); + +CREATE INDEX idx_user_role_binding_user_id ON user_role_binding(user_id); + +-- 命名空间表 +CREATE TABLE namespace ( + id BIGSERIAL PRIMARY KEY, + slug VARCHAR(64) NOT NULL UNIQUE, + display_name VARCHAR(128) NOT NULL, + type VARCHAR(32) NOT NULL, + description TEXT, + avatar_url VARCHAR(512), + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + created_by BIGINT REFERENCES user_account(id), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- 命名空间成员表 +CREATE TABLE namespace_member ( + id BIGSERIAL PRIMARY KEY, + namespace_id BIGINT NOT NULL REFERENCES namespace(id), + user_id BIGINT NOT NULL REFERENCES user_account(id), + role VARCHAR(32) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(namespace_id, user_id) +); + +CREATE INDEX idx_namespace_member_user_id ON namespace_member(user_id); +CREATE INDEX idx_namespace_member_namespace_id ON namespace_member(namespace_id); + +-- 审计日志表 +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_user_id BIGINT REFERENCES user_account(id), + action VARCHAR(64) NOT NULL, + target_type VARCHAR(64), + target_id BIGINT, + request_id VARCHAR(64), + client_ip VARCHAR(64), + user_agent VARCHAR(512), + detail_json JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_audit_log_actor ON audit_log(actor_user_id); +CREATE INDEX idx_audit_log_created_at ON audit_log(created_at); +CREATE INDEX idx_audit_log_request_id ON audit_log(request_id); + +-- 插入系统内置角色 +INSERT INTO role (code, name, description, is_system) VALUES +('SUPER_ADMIN', '超级管理员', '拥有所有权限', TRUE), +('SKILL_ADMIN', '技能管理员', '全局空间审核、提升审核、隐藏/撤回', TRUE), +('USER_ADMIN', '用户管理员', '准入审批、封禁/解封、角色分配', TRUE), +('AUDITOR', '审计员', '查看审计日志', TRUE); + +-- 插入系统权限 +INSERT INTO permission (code, name, group_code) VALUES +('skill:publish', '发布技能', 'skill'), +('skill:manage', '管理技能', 'skill'), +('skill:promote', '提升到全局', 'skill'), +('review:approve', '审核技能', 'review'), +('promotion:approve', '审核提升申请', 'promotion'), +('user:manage', '管理用户', 'user'), +('user:approve', '审批用户准入', 'user'), +('audit:read', '查看审计日志', 'audit'); + +-- 绑定角色权限 +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'SKILL_ADMIN' AND p.code IN ('review:approve', 'skill:manage', 'promotion:approve'); + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'USER_ADMIN' AND p.code IN ('user:manage', 'user:approve'); + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id FROM role r, permission p WHERE r.code = 'AUDITOR' AND p.code = 'audit:read'; + +-- 插入系统内置 @global 命名空间 +INSERT INTO namespace (slug, display_name, type, description, status) +VALUES ('global', 'Global', 'GLOBAL', 'Platform-level public namespace', 'ACTIVE'); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java new file mode 100644 index 00000000..49dd3083 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/HealthControllerTest.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldReturnHealthStatus() throws Exception { + mockMvc.perform(get("/api/v1/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java new file mode 100644 index 00000000..127059a4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.filter; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class RequestIdFilterTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldGenerateRequestIdWhenNotProvided() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")); + } + + @Test + void shouldPreserveProvidedRequestId() throws Exception { + String requestId = "test-request-123"; + mockMvc.perform(get("/actuator/health") + .header("X-Request-Id", requestId)) + .andExpect(status().isOk()) + .andExpect(header().string("X-Request-Id", requestId)); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml new file mode 100644 index 00000000..82988572 --- /dev/null +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -0,0 +1,20 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: none + database-platform: org.hibernate.dialect.H2Dialect + flyway: + enabled: false + data: + redis: + host: localhost + port: 6379 + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 851ace69..44f8bf7a 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -5,14 +5,14 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT skillhub-auth - com.skillhub + com.iflytek.skillhub skillhub-domain diff --git a/server/skillhub-domain/pom.xml b/server/skillhub-domain/pom.xml index 4629a580..32d91ae0 100644 --- a/server/skillhub-domain/pom.xml +++ b/server/skillhub-domain/pom.xml @@ -5,7 +5,7 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml index b000156a..04c21641 100644 --- a/server/skillhub-infra/pom.xml +++ b/server/skillhub-infra/pom.xml @@ -5,14 +5,14 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT skillhub-infra - com.skillhub + com.iflytek.skillhub skillhub-domain diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 0ded25aa..adb323c2 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -5,14 +5,14 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT skillhub-search - com.skillhub + com.iflytek.skillhub skillhub-domain diff --git a/server/skillhub-storage/pom.xml b/server/skillhub-storage/pom.xml index 0d3d9762..19ea55a4 100644 --- a/server/skillhub-storage/pom.xml +++ b/server/skillhub-storage/pom.xml @@ -5,7 +5,7 @@ http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - com.skillhub + com.iflytek.skillhub skillhub-parent 0.1.0-SNAPSHOT From 2991eb11e09692f7b59d6cf104ee09f29abe6911 Mon Sep 17 00:00:00 2001 From: vsxd Date: Wed, 11 Mar 2026 23:42:53 +0800 Subject: [PATCH 2/3] feat(domain/infra/auth): add domain entities, JPA repos, and auth entities Task 8: Domain layer - UserAccount entity with UserStatus lifecycle - Namespace, NamespaceMember entities with NamespaceRole - Repository interfaces for domain aggregates Task 9: Infra layer - UserAccountJpaRepository, NamespaceJpaRepository, NamespaceMemberJpaRepository - Spring Data JPA implementations Task 10: Auth entities and repositories - IdentityBinding, ApiToken, Role, Permission, RolePermission, UserRoleBinding - JPA repositories for all auth entities - UserRoleBinding with eager-loaded Role for RBAC queries --- .../skillhub/auth/entity/ApiToken.java | 77 +++++++++++++++++++ .../skillhub/auth/entity/IdentityBinding.java | 66 ++++++++++++++++ .../skillhub/auth/entity/Permission.java | 24 ++++++ .../iflytek/skillhub/auth/entity/Role.java | 35 +++++++++ .../skillhub/auth/entity/RolePermission.java | 40 ++++++++++ .../skillhub/auth/entity/UserRoleBinding.java | 37 +++++++++ .../auth/repository/ApiTokenRepository.java | 13 ++++ .../repository/IdentityBindingRepository.java | 11 +++ .../auth/repository/RoleRepository.java | 11 +++ .../repository/UserRoleBindingRepository.java | 11 +++ .../skillhub/domain/namespace/Namespace.java | 61 +++++++++++++++ .../domain/namespace/NamespaceMember.java | 46 +++++++++++ .../namespace/NamespaceMemberRepository.java | 10 +++ .../domain/namespace/NamespaceRepository.java | 9 +++ .../domain/namespace/NamespaceRole.java | 5 ++ .../domain/namespace/NamespaceStatus.java | 5 ++ .../skillhub/domain/user/UserAccount.java | 69 +++++++++++++++++ .../domain/user/UserAccountRepository.java | 8 ++ .../skillhub/domain/user/UserStatus.java | 5 ++ server/skillhub-infra/pom.xml | 4 + .../infra/jpa/NamespaceJpaRepository.java | 14 ++++ .../jpa/NamespaceMemberJpaRepository.java | 16 ++++ .../infra/jpa/UserAccountJpaRepository.java | 11 +++ 23 files changed, 588 insertions(+) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java new file mode 100644 index 00000000..0191c685 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -0,0 +1,77 @@ +package com.iflytek.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_token") +public class ApiToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "subject_type", nullable = false, length = 32) + private String subjectType = "USER"; + + @Column(name = "subject_id", nullable = false) + private Long subjectId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(name = "token_prefix", nullable = false, length = 16) + private String tokenPrefix; + + @Column(name = "token_hash", nullable = false, unique = true, length = 64) + private String tokenHash; + + @Column(name = "scope_json", nullable = false, columnDefinition = "jsonb") + private String scopeJson; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "revoked_at") + private LocalDateTime revokedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected ApiToken() {} + + public ApiToken(Long userId, String name, String tokenPrefix, String tokenHash, String scopeJson) { + this.subjectType = "USER"; + this.subjectId = userId; + this.userId = userId; + this.name = name; + this.tokenPrefix = tokenPrefix; + this.tokenHash = tokenHash; + this.scopeJson = scopeJson; + } + + @PrePersist + void prePersist() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public Long getUserId() { return userId; } + public String getName() { return name; } + public String getTokenPrefix() { return tokenPrefix; } + public String getTokenHash() { return tokenHash; } + public String getScopeJson() { return scopeJson; } + public LocalDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } + public LocalDateTime getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; } + public LocalDateTime getRevokedAt() { return revokedAt; } + public void setRevokedAt(LocalDateTime revokedAt) { this.revokedAt = revokedAt; } + public LocalDateTime getCreatedAt() { return createdAt; } + public boolean isRevoked() { return revokedAt != null; } + public boolean isExpired() { return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); } + public boolean isValid() { return !isRevoked() && !isExpired(); } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java new file mode 100644 index 00000000..8a25a0bc --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "identity_binding", + uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"})) +public class IdentityBinding { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "provider_code", nullable = false, length = 64) + private String providerCode; + + @Column(nullable = false, length = 256) + private String subject; + + @Column(name = "login_name", length = 128) + private String loginName; + + @Column(name = "extra_json", columnDefinition = "jsonb") + private String extraJson; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected IdentityBinding() {} + + public IdentityBinding(Long userId, String providerCode, String subject, String loginName) { + this.userId = userId; + this.providerCode = providerCode; + this.subject = subject; + this.loginName = loginName; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getProviderCode() { return providerCode; } + public void setProviderCode(String providerCode) { this.providerCode = providerCode; } + public String getSubject() { return subject; } + public void setSubject(String subject) { this.subject = subject; } + public String getLoginName() { return loginName; } + public void setLoginName(String loginName) { this.loginName = loginName; } + public String getExtraJson() { return extraJson; } + public void setExtraJson(String extraJson) { this.extraJson = extraJson; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java new file mode 100644 index 00000000..8f613859 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Permission.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.entity; + +import jakarta.persistence.*; + +@Entity +@Table(name = "permission") +public class Permission { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 128) + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(name = "group_code", length = 64) + private String groupCode; + + public Long getId() { return id; } + public String getCode() { return code; } + public String getName() { return name; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java new file mode 100644 index 00000000..6c50924b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "role") +public class Role { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String code; + + @Column(nullable = false, length = 128) + private String name; + + @Column(length = 512) + private String description; + + @Column(name = "is_system", nullable = false) + private boolean system; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void prePersist() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public String getCode() { return code; } + public String getName() { return name; } + public boolean isSystem() { return system; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java new file mode 100644 index 00000000..71e71aee --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/RolePermission.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.io.Serializable; +import java.util.Objects; + +@Entity +@Table(name = "role_permission") +@IdClass(RolePermission.RolePermissionId.class) +public class RolePermission { + @Id + @Column(name = "role_id") + private Long roleId; + + @Id + @Column(name = "permission_id") + private Long permissionId; + + public Long getRoleId() { return roleId; } + public Long getPermissionId() { return permissionId; } + + public static class RolePermissionId implements Serializable { + private Long roleId; + private Long permissionId; + + public RolePermissionId() {} + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RolePermissionId that)) return false; + return Objects.equals(roleId, that.roleId) && Objects.equals(permissionId, that.permissionId); + } + + @Override + public int hashCode() { + return Objects.hash(roleId, permissionId); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java new file mode 100644 index 00000000..e79db0cd --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java @@ -0,0 +1,37 @@ +package com.iflytek.skillhub.auth.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_role_binding", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role_id"})) +public class UserRoleBinding { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "role_id", nullable = false) + private Role role; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected UserRoleBinding() {} + + public UserRoleBinding(Long userId, Role role) { + this.userId = userId; + this.role = role; + } + + @PrePersist + void prePersist() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public Long getUserId() { return userId; } + public Role getRole() { return role; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java new file mode 100644 index 00000000..0c60b2de --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.repository; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + +@Repository +public interface ApiTokenRepository extends JpaRepository { + Optional findByTokenHash(String tokenHash); + List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(Long userId); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java new file mode 100644 index 00000000..efad961f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/IdentityBindingRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.repository; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface IdentityBindingRepository extends JpaRepository { + Optional findByProviderCodeAndSubject(String providerCode, String subject); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java new file mode 100644 index 00000000..2fcc3a46 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/RoleRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.repository; + +import com.iflytek.skillhub.auth.entity.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface RoleRepository extends JpaRepository { + Optional findByCode(String code); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java new file mode 100644 index 00000000..73f3f71d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.repository; + +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public interface UserRoleBindingRepository extends JpaRepository { + List findByUserId(Long userId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java new file mode 100644 index 00000000..cac43e25 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "namespace") +public class Namespace { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String slug; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NamespaceStatus status = NamespaceStatus.ACTIVE; + + @Column(length = 512) + private String description; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected Namespace() {} + + public Namespace(String slug, String displayName, Long createdBy) { + this.slug = slug; + this.displayName = displayName; + this.createdBy = createdBy; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public String getSlug() { return slug; } + public String getDisplayName() { return displayName; } + public NamespaceStatus getStatus() { return status; } + public Long getCreatedBy() { return createdBy; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java new file mode 100644 index 00000000..21e83f12 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java @@ -0,0 +1,46 @@ +package com.iflytek.skillhub.domain.namespace; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "namespace_member", + uniqueConstraints = @UniqueConstraint(columnNames = {"namespace_id", "user_id"})) +public class NamespaceMember { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "namespace_id", nullable = false) + private Long namespaceId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NamespaceRole role; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected NamespaceMember() {} + + public NamespaceMember(Long namespaceId, Long userId, NamespaceRole role) { + this.namespaceId = namespaceId; + this.userId = userId; + this.role = role; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public Long getNamespaceId() { return namespaceId; } + public Long getUserId() { return userId; } + public NamespaceRole getRole() { return role; } + public void setRole(NamespaceRole role) { this.role = role; } + public LocalDateTime getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java new file mode 100644 index 00000000..2ef778a2 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.List; +import java.util.Optional; + +public interface NamespaceMemberRepository { + Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); + List findByUserId(Long userId); + NamespaceMember save(NamespaceMember member); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java new file mode 100644 index 00000000..78421ffa --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRepository.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.domain.namespace; + +import java.util.Optional; + +public interface NamespaceRepository { + Optional findById(Long id); + Optional findBySlug(String slug); + Namespace save(Namespace namespace); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java new file mode 100644 index 00000000..9a14652e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceRole.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.domain.namespace; + +public enum NamespaceRole { + OWNER, ADMIN, MEMBER +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java new file mode 100644 index 00000000..b04cb411 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceStatus.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.domain.namespace; + +public enum NamespaceStatus { + ACTIVE, FROZEN, ARCHIVED +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java new file mode 100644 index 00000000..7956f141 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.domain.user; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_account") +public class UserAccount { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Column(length = 256) + private String email; + + @Column(name = "avatar_url", length = 512) + private String avatarUrl; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private UserStatus status = UserStatus.ACTIVE; + + @Column(name = "merged_to_user_id") + private Long mergedToUserId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + protected UserAccount() {} + + public UserAccount(String displayName, String email, String avatarUrl) { + this.displayName = displayName; + this.email = email; + this.avatarUrl = avatarUrl; + this.status = UserStatus.ACTIVE; + } + + @PrePersist + void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getAvatarUrl() { return avatarUrl; } + public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } + public UserStatus getStatus() { return status; } + public void setStatus(UserStatus status) { this.status = status; } + public Long getMergedToUserId() { return mergedToUserId; } + public void setMergedToUserId(Long mergedToUserId) { this.mergedToUserId = mergedToUserId; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public boolean isActive() { return this.status == UserStatus.ACTIVE; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java new file mode 100644 index 00000000..003b536a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.user; + +import java.util.Optional; + +public interface UserAccountRepository { + Optional findById(Long id); + UserAccount save(UserAccount user); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java new file mode 100644 index 00000000..092c83bc --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserStatus.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.domain.user; + +public enum UserStatus { + ACTIVE, PENDING, DISABLED, MERGED +} diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml index 04c21641..e3d346ef 100644 --- a/server/skillhub-infra/pom.xml +++ b/server/skillhub-infra/pom.xml @@ -15,5 +15,9 @@ com.iflytek.skillhub skillhub-domain + + org.springframework.boot + spring-boot-starter-data-jpa + diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java new file mode 100644 index 00000000..9a326b7e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceJpaRepository.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NamespaceJpaRepository + extends JpaRepository, NamespaceRepository { + Optional findBySlug(String slug); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java new file mode 100644 index 00000000..5c30d78c --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface NamespaceMemberJpaRepository + extends JpaRepository, NamespaceMemberRepository { + Optional findByNamespaceIdAndUserId(Long namespaceId, Long userId); + List findByUserId(Long userId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java new file mode 100644 index 00000000..2968eccf --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserAccountJpaRepository + extends JpaRepository, UserAccountRepository { +} From 5c4be0a26f30c19788ac004ada8e218c16326c28 Mon Sep 17 00:00:00 2001 From: vsxd Date: Wed, 11 Mar 2026 23:48:16 +0800 Subject: [PATCH 3/3] feat(auth): add OAuth2 login, access policy, API token, and RBAC Task 10b: OAuth Claims + Access Policy - OAuthClaims record, OAuthClaimsExtractor SPI, GitHubClaimsExtractor - AccessPolicy SPI with Open/EmailDomain/ProviderAllowlist/SubjectWhitelist - AccessPolicyFactory with config-driven strategy selection - 8 unit tests for access policies Task 11: Identity binding + OAuth2 user service - PlatformPrincipal session-serializable record - IdentityBindingService: bind-or-create on OAuth login - CustomOAuth2UserService: policy evaluation + identity binding - OAuth2LoginSuccessHandler: store principal in session Task 12: API Token + RBAC - ApiTokenService: SHA-256 hashed token create/validate/revoke - ApiTokenAuthenticationFilter: Bearer token authentication - RbacService: role/permission queries with SUPER_ADMIN bypass --- server/skillhub-auth/pom.xml | 4 + .../auth/identity/IdentityBindingService.java | 69 ++++++++++++++++ .../auth/oauth/CustomOAuth2UserService.java | 67 ++++++++++++++++ .../auth/oauth/GitHubClaimsExtractor.java | 24 ++++++ .../auth/oauth/OAuth2LoginSuccessHandler.java | 32 ++++++++ .../skillhub/auth/oauth/OAuthClaims.java | 12 +++ .../auth/oauth/OAuthClaimsExtractor.java | 8 ++ .../skillhub/auth/policy/AccessDecision.java | 5 ++ .../skillhub/auth/policy/AccessPolicy.java | 7 ++ .../auth/policy/AccessPolicyFactory.java | 31 ++++++++ .../auth/policy/EmailDomainAccessPolicy.java | 20 +++++ .../auth/policy/OpenAccessPolicy.java | 10 +++ .../policy/ProviderAllowlistAccessPolicy.java | 18 +++++ .../policy/SubjectWhitelistAccessPolicy.java | 19 +++++ .../skillhub/auth/rbac/PlatformPrincipal.java | 13 +++ .../skillhub/auth/rbac/RbacService.java | 60 ++++++++++++++ .../token/ApiTokenAuthenticationFilter.java | 63 +++++++++++++++ .../skillhub/auth/token/ApiTokenService.java | 79 +++++++++++++++++++ .../auth/policy/AccessPolicyTest.java | 66 ++++++++++++++++ 19 files changed, 607 insertions(+) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 44f8bf7a..52785507 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -23,6 +23,10 @@ org.springframework.boot spring-boot-starter-oauth2-client + + org.springframework.boot + spring-boot-starter-web + org.springframework.boot spring-boot-starter-data-jpa diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java new file mode 100644 index 00000000..658be587 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityBindingService.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.auth.entity.IdentityBinding; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class IdentityBindingService { + + private final IdentityBindingRepository bindingRepo; + private final UserAccountRepository userRepo; + private final UserRoleBindingRepository roleBindingRepo; + + public IdentityBindingService(IdentityBindingRepository bindingRepo, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo) { + this.bindingRepo = bindingRepo; + this.userRepo = userRepo; + this.roleBindingRepo = roleBindingRepo; + } + + @Transactional + public PlatformPrincipal bindOrCreate(OAuthClaims claims, UserStatus initialStatus) { + IdentityBinding binding = bindingRepo + .findByProviderCodeAndSubject(claims.provider(), claims.subject()) + .orElse(null); + + UserAccount user; + if (binding != null) { + user = userRepo.findById(binding.getUserId()) + .orElseThrow(() -> new IllegalStateException("User not found for binding")); + user.setDisplayName(claims.providerLogin()); + if (claims.email() != null) user.setEmail(claims.email()); + if (claims.extra().get("avatar_url") != null) { + user.setAvatarUrl((String) claims.extra().get("avatar_url")); + } + user = userRepo.save(user); + } else { + user = new UserAccount( + claims.providerLogin(), + claims.email(), + (String) claims.extra().get("avatar_url") + ); + user.setStatus(initialStatus); + user = userRepo.save(user); + + binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin()); + bindingRepo.save(binding); + } + + Set roles = roleBindingRepo.findByUserId(user.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + + return new PlatformPrincipal( + user.getId(), user.getDisplayName(), user.getEmail(), + user.getAvatarUrl(), claims.provider(), roles + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java new file mode 100644 index 00000000..151168f0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java @@ -0,0 +1,67 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.identity.IdentityBindingService; +import com.iflytek.skillhub.auth.policy.AccessDecision; +import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.user.UserStatus; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class CustomOAuth2UserService implements OAuth2UserService { + + private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final Map extractors; + private final AccessPolicy accessPolicy; + private final IdentityBindingService identityBindingService; + + public CustomOAuth2UserService(List extractorList, + AccessPolicy accessPolicy, + IdentityBindingService identityBindingService) { + this.extractors = extractorList.stream() + .collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity())); + this.accessPolicy = accessPolicy; + this.identityBindingService = identityBindingService; + } + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = delegate.loadUser(request); + String registrationId = request.getClientRegistration().getRegistrationId(); + + OAuthClaimsExtractor extractor = extractors.get(registrationId); + if (extractor == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null)); + } + + OAuthClaims claims = extractor.extract(oAuth2User); + AccessDecision decision = accessPolicy.evaluate(claims); + + UserStatus initialStatus = switch (decision) { + case ALLOW -> UserStatus.ACTIVE; + case PENDING_APPROVAL -> UserStatus.PENDING; + case DENY -> throw new OAuth2AuthenticationException( + new OAuth2Error("access_denied", "Access denied by policy", null)); + }; + + PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, initialStatus); + + var attrs = new HashMap<>(oAuth2User.getAttributes()); + attrs.put("platformPrincipal", principal); + + return new DefaultOAuth2User(oAuth2User.getAuthorities(), attrs, "login"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java new file mode 100644 index 00000000..a79bc367 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitHubClaimsExtractor.java @@ -0,0 +1,24 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; +import java.util.Map; + +@Component +public class GitHubClaimsExtractor implements OAuthClaimsExtractor { + @Override + public String getProvider() { return "github"; } + + @Override + public OAuthClaims extract(OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + return new OAuthClaims( + "github", + String.valueOf(attrs.get("id")), + (String) attrs.get("email"), + attrs.get("email") != null, + (String) attrs.get("login"), + attrs + ); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..083c9966 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginSuccessHandler.java @@ -0,0 +1,32 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + public OAuth2LoginSuccessHandler() { + setDefaultTargetUrl("/"); + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) { + PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal"); + if (principal != null) { + request.getSession().setAttribute("platformPrincipal", principal); + } + } + super.onAuthenticationSuccess(request, response, authentication); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java new file mode 100644 index 00000000..679d1a39 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaims.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.auth.oauth; + +import java.util.Map; + +public record OAuthClaims( + String provider, + String subject, + String email, + boolean emailVerified, + String providerLogin, + Map extra +) {} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java new file mode 100644 index 00000000..d6e347df --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/OAuthClaimsExtractor.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.springframework.security.oauth2.core.user.OAuth2User; + +public interface OAuthClaimsExtractor { + String getProvider(); + OAuthClaims extract(OAuth2User oAuth2User); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java new file mode 100644 index 00000000..1aea1213 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.auth.policy; + +public enum AccessDecision { + ALLOW, DENY, PENDING_APPROVAL +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java new file mode 100644 index 00000000..f2049308 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.oauth.OAuthClaims; + +public interface AccessPolicy { + AccessDecision evaluate(OAuthClaims claims); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java new file mode 100644 index 00000000..79602966 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicyFactory.java @@ -0,0 +1,31 @@ +package com.iflytek.skillhub.auth.policy; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import java.util.List; +import java.util.Set; + +@Configuration +@ConfigurationProperties(prefix = "skillhub.access-policy") +public class AccessPolicyFactory { + private String mode = "OPEN"; + private List allowedEmailDomains = List.of(); + private List allowedProviders = List.of(); + private List whitelistedSubjects = List.of(); + + @Bean + public AccessPolicy accessPolicy() { + return switch (mode.toUpperCase()) { + case "EMAIL_DOMAIN" -> new EmailDomainAccessPolicy(Set.copyOf(allowedEmailDomains)); + case "PROVIDER_ALLOWLIST" -> new ProviderAllowlistAccessPolicy(Set.copyOf(allowedProviders)); + case "SUBJECT_WHITELIST" -> new SubjectWhitelistAccessPolicy(Set.copyOf(whitelistedSubjects)); + default -> new OpenAccessPolicy(); + }; + } + + public void setMode(String mode) { this.mode = mode; } + public void setAllowedEmailDomains(List d) { this.allowedEmailDomains = d; } + public void setAllowedProviders(List p) { this.allowedProviders = p; } + public void setWhitelistedSubjects(List s) { this.whitelistedSubjects = s; } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java new file mode 100644 index 00000000..fe9d442f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/EmailDomainAccessPolicy.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.util.Set; + +public class EmailDomainAccessPolicy implements AccessPolicy { + private final Set allowedDomains; + + public EmailDomainAccessPolicy(Set allowedDomains) { + this.allowedDomains = allowedDomains; + } + + @Override + public AccessDecision evaluate(OAuthClaims claims) { + if (claims.email() == null) return AccessDecision.DENY; + String domain = claims.email().substring(claims.email().indexOf('@') + 1); + return allowedDomains.contains(domain.toLowerCase()) + ? AccessDecision.ALLOW : AccessDecision.DENY; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java new file mode 100644 index 00000000..c0ef33ba --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/OpenAccessPolicy.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.oauth.OAuthClaims; + +public class OpenAccessPolicy implements AccessPolicy { + @Override + public AccessDecision evaluate(OAuthClaims claims) { + return AccessDecision.ALLOW; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java new file mode 100644 index 00000000..2457f123 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/ProviderAllowlistAccessPolicy.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.util.Set; + +public class ProviderAllowlistAccessPolicy implements AccessPolicy { + private final Set allowedProviders; + + public ProviderAllowlistAccessPolicy(Set allowedProviders) { + this.allowedProviders = allowedProviders; + } + + @Override + public AccessDecision evaluate(OAuthClaims claims) { + return allowedProviders.contains(claims.provider()) + ? AccessDecision.ALLOW : AccessDecision.DENY; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java new file mode 100644 index 00000000..daf6acaf --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/SubjectWhitelistAccessPolicy.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import java.util.Set; + +public class SubjectWhitelistAccessPolicy implements AccessPolicy { + private final Set whitelistedSubjects; + + public SubjectWhitelistAccessPolicy(Set whitelistedSubjects) { + this.whitelistedSubjects = whitelistedSubjects; + } + + @Override + public AccessDecision evaluate(OAuthClaims claims) { + String key = claims.provider() + ":" + claims.subject(); + return whitelistedSubjects.contains(key) + ? AccessDecision.ALLOW : AccessDecision.DENY; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java new file mode 100644 index 00000000..6891d87d --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/PlatformPrincipal.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.rbac; + +import java.io.Serializable; +import java.util.Set; + +public record PlatformPrincipal( + Long userId, + String displayName, + String email, + String avatarUrl, + String oauthProvider, + Set platformRoles +) implements Serializable {} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java new file mode 100644 index 00000000..b17f2c87 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/rbac/RbacService.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.auth.rbac; + +import com.iflytek.skillhub.auth.entity.Permission; +import com.iflytek.skillhub.auth.entity.RolePermission; +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import org.springframework.stereotype.Service; + +import jakarta.persistence.EntityManager; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class RbacService { + + private final UserRoleBindingRepository roleBindingRepo; + private final EntityManager entityManager; + + public RbacService(UserRoleBindingRepository roleBindingRepo, EntityManager entityManager) { + this.roleBindingRepo = roleBindingRepo; + this.entityManager = entityManager; + } + + public Set getUserRoleCodes(Long userId) { + return roleBindingRepo.findByUserId(userId).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + } + + public Set getUserPermissions(Long userId) { + List bindings = roleBindingRepo.findByUserId(userId); + Set roleIds = bindings.stream() + .map(rb -> rb.getRole().getId()) + .collect(Collectors.toSet()); + + if (roleIds.isEmpty()) return Set.of(); + + // Check if user has SUPER_ADMIN role - grant all permissions + boolean isSuperAdmin = bindings.stream() + .anyMatch(rb -> "SUPER_ADMIN".equals(rb.getRole().getCode())); + if (isSuperAdmin) { + return entityManager.createQuery("SELECT p.code FROM Permission p", String.class) + .getResultList().stream().collect(Collectors.toSet()); + } + + return entityManager.createQuery( + "SELECT p.code FROM RolePermission rp JOIN Permission p ON rp.permissionId = p.id WHERE rp.roleId IN :roleIds", String.class) + .setParameter("roleIds", roleIds) + .getResultList().stream().collect(Collectors.toSet()); + } + + public boolean hasPermission(Long userId, String permissionCode) { + return getUserPermissions(userId).contains(permissionCode); + } + + public boolean hasRole(Long userId, String roleCode) { + return getUserRoleCodes(userId).contains(roleCode); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java new file mode 100644 index 00000000..5e8a5b78 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenAuthenticationFilter.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.auth.token; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class ApiTokenAuthenticationFilter extends OncePerRequestFilter { + + private static final String AUTH_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + private final ApiTokenService apiTokenService; + private final UserAccountRepository userRepo; + private final UserRoleBindingRepository roleBindingRepo; + + public ApiTokenAuthenticationFilter(ApiTokenService apiTokenService, + UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo) { + this.apiTokenService = apiTokenService; + this.userRepo = userRepo; + this.roleBindingRepo = roleBindingRepo; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authHeader = request.getHeader(AUTH_HEADER); + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + String rawToken = authHeader.substring(BEARER_PREFIX.length()); + apiTokenService.validateToken(rawToken).ifPresent(token -> { + apiTokenService.touchLastUsed(token); + userRepo.findById(token.getUserId()).ifPresent(user -> { + Set roles = roleBindingRepo.findByUserId(user.getId()).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + PlatformPrincipal principal = new PlatformPrincipal( + user.getId(), user.getDisplayName(), user.getEmail(), + user.getAvatarUrl(), "api_token", roles + ); + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + }); + }); + } + filterChain.doFilter(request, response); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java new file mode 100644 index 00000000..a15a10ec --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -0,0 +1,79 @@ +package com.iflytek.skillhub.auth.token; + +import com.iflytek.skillhub.auth.entity.ApiToken; +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; + +@Service +public class ApiTokenService { + + private static final String TOKEN_PREFIX = "sk_"; + private static final int TOKEN_BYTES = 32; + private final SecureRandom secureRandom = new SecureRandom(); + private final ApiTokenRepository tokenRepo; + + public ApiTokenService(ApiTokenRepository tokenRepo) { + this.tokenRepo = tokenRepo; + } + + public record TokenCreateResult(String rawToken, ApiToken entity) {} + + @Transactional + public TokenCreateResult createToken(Long userId, String name, String scopeJson) { + byte[] randomBytes = new byte[TOKEN_BYTES]; + secureRandom.nextBytes(randomBytes); + String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + String tokenHash = sha256(rawToken); + String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8)); + + ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson); + token = tokenRepo.save(token); + return new TokenCreateResult(rawToken, token); + } + + public Optional validateToken(String rawToken) { + String hash = sha256(rawToken); + return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid); + } + + @Transactional + public void revokeToken(Long tokenId, Long userId) { + tokenRepo.findById(tokenId) + .filter(t -> t.getUserId().equals(userId)) + .ifPresent(t -> { + t.setRevokedAt(LocalDateTime.now()); + tokenRepo.save(t); + }); + } + + public List listActiveTokens(Long userId) { + return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId); + } + + @Transactional + public void touchLastUsed(ApiToken token) { + token.setLastUsedAt(LocalDateTime.now()); + tokenRepo.save(token); + } + + private String sha256(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java new file mode 100644 index 00000000..df7f1c52 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/AccessPolicyTest.java @@ -0,0 +1,66 @@ +package com.iflytek.skillhub.auth.policy; + +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import org.junit.jupiter.api.Test; +import java.util.Map; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; + +class AccessPolicyTest { + + @Test + void openPolicy_alwaysAllows() { + var policy = new OpenAccessPolicy(); + var claims = new OAuthClaims("github", "123", "user@any.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void emailDomainPolicy_allowsMatchingDomain() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", "user@company.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void emailDomainPolicy_deniesNonMatchingDomain() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", "user@other.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + + @Test + void emailDomainPolicy_deniesNullEmail() { + var policy = new EmailDomainAccessPolicy(Set.of("company.com")); + var claims = new OAuthClaims("github", "123", null, false, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + + @Test + void providerAllowlistPolicy_allowsMatchingProvider() { + var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); + var claims = new OAuthClaims("github", "123", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void providerAllowlistPolicy_deniesNonMatchingProvider() { + var policy = new ProviderAllowlistAccessPolicy(Set.of("github")); + var claims = new OAuthClaims("google", "123", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } + + @Test + void subjectWhitelistPolicy_allowsMatchingSubject() { + var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345")); + var claims = new OAuthClaims("github", "12345", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.ALLOW); + } + + @Test + void subjectWhitelistPolicy_deniesNonMatchingSubject() { + var policy = new SubjectWhitelistAccessPolicy(Set.of("github:12345")); + var claims = new OAuthClaims("github", "99999", "u@a.com", true, "user", Map.of()); + assertThat(policy.evaluate(claims)).isEqualTo(AccessDecision.DENY); + } +}