feat(auth): complete Chunk 2 - security config, controllers, mock auth

Task 14: Spring Security config
- SecurityConfig with OAuth2 login, CSRF Cookie-to-Header, CLI API exempt
- ApiTokenAuthenticationFilter before UsernamePasswordAuthenticationFilter
- Spring Session Redis + OAuth2 client config in application.yml

Task 15: MockAuthFilter
- @Profile("local") filter reads X-Mock-User-Id header
- Creates PlatformPrincipal and sets SecurityContext for local dev

Task 16: AuthController + TokenController
- GET /api/v1/auth/me: current user info from session
- GET /api/v1/auth/providers: available OAuth providers
- POST/GET/DELETE /api/v1/tokens: create, list, revoke API tokens

Task 17: Enhanced GlobalExceptionHandler
- ErrorResponse with requestId and timestamp
- 400 for IllegalArgumentException, 500 catch-all with MDC requestId
This commit is contained in:
vsxd 2026-03-11 23:55:15 +08:00
parent cfdb0985a9
commit 6380677fcf
10 changed files with 309 additions and 33 deletions

View file

@ -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();
}
}

View file

@ -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<Map<String, Object>> 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<Map<String, Object>> providers() {
var github = Map.of(
"id", "github",
"name", "GitHub",
"authorizationUrl", "/oauth2/authorization/github"
);
return ResponseEntity.ok(Map.of("data", List.of(github)));
}
}

View file

@ -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<Map<String, Object>> create(
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestBody Map<String, Object> 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<Void> revoke(
@AuthenticationPrincipal PlatformPrincipal principal,
@PathVariable Long id) {
apiTokenService.revokeToken(id, principal.userId());
return ResponseEntity.noContent().build();
}
}

View file

@ -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());
}
}

View file

@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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));
}
}

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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();
}
}

View file

@ -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<String> 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);
}
}