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 deleted file mode 100644 index 29b14f7e..00000000 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SecurityConfig.java +++ /dev/null @@ -1,22 +0,0 @@ -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/AuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java new file mode 100644 index 00000000..d3ebfe4b --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/AuthController.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import jakarta.servlet.http.HttpSession; +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.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + @GetMapping("/me") + public ResponseEntity> me(HttpSession session) { + PlatformPrincipal principal = (PlatformPrincipal) session.getAttribute("platformPrincipal"); + if (principal == null) { + return ResponseEntity.status(401).build(); + } + return ResponseEntity.ok(Map.of( + "userId", principal.userId(), + "displayName", principal.displayName(), + "email", principal.email() != null ? principal.email() : "", + "avatarUrl", principal.avatarUrl() != null ? principal.avatarUrl() : "", + "oauthProvider", principal.oauthProvider(), + "platformRoles", principal.platformRoles() + )); + } + + @GetMapping("/providers") + public ResponseEntity> providers() { + var github = Map.of( + "id", "github", + "name", "GitHub", + "authorizationUrl", "/oauth2/authorization/github" + ); + return ResponseEntity.ok(Map.of("data", List.of(github))); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java new file mode 100644 index 00000000..1c06c248 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.controller; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.token.ApiTokenService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/tokens") +public class TokenController { + + private final ApiTokenService apiTokenService; + + public TokenController(ApiTokenService apiTokenService) { + this.apiTokenService = apiTokenService; + } + + @PostMapping + public ResponseEntity> create( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestBody Map body) { + String name = (String) body.get("name"); + String scopeJson = body.containsKey("scopes") + ? body.get("scopes").toString() : "[\"skill:read\",\"skill:publish\"]"; + + var result = apiTokenService.createToken(principal.userId(), name, scopeJson); + return ResponseEntity.ok(Map.of( + "token", result.rawToken(), + "id", result.entity().getId(), + "name", result.entity().getName(), + "tokenPrefix", result.entity().getTokenPrefix() + )); + } + + @GetMapping + public ResponseEntity list(@AuthenticationPrincipal PlatformPrincipal principal) { + var tokens = apiTokenService.listActiveTokens(principal.userId()); + var result = tokens.stream().map(t -> Map.of( + "id", t.getId(), + "name", t.getName(), + "tokenPrefix", t.getTokenPrefix(), + "createdAt", t.getCreatedAt().toString(), + "expiresAt", t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", + "lastUsedAt", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" + )).toList(); + return ResponseEntity.ok(Map.of("data", result)); + } + + @DeleteMapping("/{id}") + public ResponseEntity revoke( + @AuthenticationPrincipal PlatformPrincipal principal, + @PathVariable Long id) { + apiTokenService.revokeToken(id, principal.userId()); + return ResponseEntity.noContent().build(); + } +} 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 index 194b6861..66a91977 100644 --- 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 @@ -1,7 +1,19 @@ package com.iflytek.skillhub.dto; +import java.time.Instant; + public record ErrorResponse( int status, String error, - String message -) {} + String message, + String requestId, + Instant timestamp +) { + public ErrorResponse(int status, String error, String message, String requestId) { + this(status, error, message, requestId, Instant.now()); + } + + public ErrorResponse(int status, String error, String message) { + this(status, error, message, null, Instant.now()); + } +} 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 index a8962949..c74d22a3 100644 --- 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 @@ -1,27 +1,35 @@ package com.iflytek.skillhub.exception; import com.iflytek.skillhub.dto.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; 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(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException ex, + HttpServletRequest request) { + String requestId = MDC.get("requestId"); + return ResponseEntity.badRequest().body( + new ErrorResponse(400, "Bad Request", ex.getMessage(), requestId)); + } + @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); + public ResponseEntity handleGlobalException(Exception ex, + HttpServletRequest request) { + String requestId = MDC.get("requestId"); + logger.error("Unhandled exception [requestId={}]", requestId, ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + new ErrorResponse(500, "Internal Server Error", + "An unexpected error occurred", requestId)); } } diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 899183dc..f79eb483 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -13,6 +13,15 @@ spring: redis: host: localhost port: 6379 + session: + store-type: redis + security: + oauth2: + client: + registration: + github: + client-id: ${OAUTH2_GITHUB_CLIENT_ID:local-placeholder} + client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder} logging: level: diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index df379f38..67b74ae3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -23,6 +23,25 @@ spring: password: skillhub_dev hikari: maximum-pool-size: 10 + session: + store-type: redis + redis: + namespace: skillhub:session + security: + oauth2: + client: + registration: + github: + client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder} + scope: read:user,user:email + provider: + github: + user-info-uri: https://api.github.com/user + +skillhub: + access-policy: + mode: OPEN management: endpoints: diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index 82988572..afe91d32 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -18,3 +18,18 @@ spring: exclude: - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration - org.springframework.boot.autoconfigure.session.SessionAutoConfiguration + security: + oauth2: + client: + registration: + github: + client-id: test-client-id + client-secret: test-client-secret + scope: read:user,user:email + provider: + github: + user-info-uri: https://api.github.com/user + +skillhub: + access-policy: + mode: OPEN diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java new file mode 100644 index 00000000..c3be6327 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.auth.config; + +import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService; +import com.iflytek.skillhub.auth.oauth.OAuth2LoginSuccessHandler; +import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; +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; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler successHandler; + private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter; + + public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, + OAuth2LoginSuccessHandler successHandler, + ApiTokenAuthenticationFilter apiTokenAuthenticationFilter) { + this.customOAuth2UserService = customOAuth2UserService; + this.successHandler = successHandler; + this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + var csrfHandler = new CsrfTokenRequestAttributeHandler(); + csrfHandler.setCsrfRequestAttributeName(null); + + http + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(csrfHandler) + .ignoringRequestMatchers("/api/v1/cli/**", "/api/compat/**") + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/health", + "/api/v1/auth/providers", + "/api/v1/skills/**", + "/api/v1/namespaces/**", + "/actuator/health", + "/v3/api-docs/**", + "/swagger-ui/**", + "/.well-known/**" + ).permitAll() + .requestMatchers("/api/v1/admin/**").hasAnyRole("SUPER_ADMIN", "SKILL_ADMIN", "USER_ADMIN", "AUDITOR") + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(successHandler) + ) + .logout(logout -> logout + .logoutUrl("/api/v1/auth/logout") + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .deleteCookies("SESSION") + ) + .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java new file mode 100644 index 00000000..786a1b1a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/mock/MockAuthFilter.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.auth.mock; + +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.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.Set; +import java.util.stream.Collectors; + +@Component +@Profile("local") +@Order(-100) +public class MockAuthFilter extends OncePerRequestFilter { + + private final UserAccountRepository userRepo; + private final UserRoleBindingRepository roleBindingRepo; + + public MockAuthFilter(UserAccountRepository userRepo, + UserRoleBindingRepository roleBindingRepo) { + this.userRepo = userRepo; + this.roleBindingRepo = roleBindingRepo; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String mockUserId = request.getHeader("X-Mock-User-Id"); + if (mockUserId != null && SecurityContextHolder.getContext().getAuthentication() == null) { + Long userId = Long.parseLong(mockUserId); + userRepo.findById(userId) + .filter(UserAccount::isActive) + .ifPresent(user -> { + Set roles = roleBindingRepo.findByUserId(userId).stream() + .map(rb -> rb.getRole().getCode()) + .collect(Collectors.toSet()); + var principal = new PlatformPrincipal( + user.getId(), user.getDisplayName(), user.getEmail(), + user.getAvatarUrl(), "mock", roles + ); + var authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .toList(); + var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + request.getSession().setAttribute("platformPrincipal", principal); + }); + } + filterChain.doFilter(request, response); + } +}