Fix phase1 auth flow gaps

This commit is contained in:
vsxd 2026-03-12 00:26:33 +08:00
parent e6956e79fc
commit 404692d671
15 changed files with 291 additions and 17 deletions

View file

@ -0,0 +1,32 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: skillhub
annotations:
nginx.ingress.kubernetes.io/limit-rps: "10"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "3"
nginx.ingress.kubernetes.io/limit-connections: "20"
nginx.ingress.kubernetes.io/server-snippet: |
location ~ ^/(oauth2/authorization|login/oauth2/code|api/v1/auth|api/v1/search|api/v1/skills/.*/download|api/v1/namespaces) {
limit_req zone=default burst=30 nodelay;
}
spec:
ingressClassName: nginx
rules:
- host: skills.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: skillhub-web
port:
number: 80
- path: /api
pathType: Prefix
backend:
service:
name: skillhub-server
port:
number: 8080

View file

@ -70,6 +70,11 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>

View file

@ -1,8 +1,9 @@
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.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@ -15,9 +16,9 @@ import java.util.Map;
public class AuthController {
@GetMapping("/me")
public ResponseEntity<Map<String, Object>> me(HttpSession session) {
PlatformPrincipal principal = (PlatformPrincipal) session.getAttribute("platformPrincipal");
if (principal == null) {
public ResponseEntity<Map<String, Object>> me(@AuthenticationPrincipal PlatformPrincipal principal,
Authentication authentication) {
if (principal == null || authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.ok(Map.of(

View file

@ -23,6 +23,10 @@ spring:
password: skillhub_dev
hikari:
maximum-pool-size: 10
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
session:
store-type: redis
redis:

View file

@ -0,0 +1,67 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Set;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
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 AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void meShouldReturnUnauthorizedForAnonymousRequest() throws Exception {
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isUnauthorized());
}
@Test
void meShouldReturnCurrentPrincipal() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
42L,
"tester",
"tester@example.com",
"https://example.com/avatar.png",
"github",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
mockMvc.perform(get("/api/v1/auth/me").with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(42))
.andExpect(jsonPath("$.displayName").value("tester"))
.andExpect(jsonPath("$.oauthProvider").value("github"))
.andExpect(jsonPath("$.platformRoles[0]").value("SUPER_ADMIN"));
}
@Test
void providersShouldExposeGithubLoginEntry() throws Exception {
mockMvc.perform(get("/api/v1/auth/providers"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].id").value("github"))
.andExpect(jsonPath("$.data[0].authorizationUrl").value("/oauth2/authorization/github"));
}
}

View file

@ -0,0 +1 @@
mock-maker-subclass

View file

@ -1,16 +1,21 @@
package com.iflytek.skillhub.auth.config;
import com.iflytek.skillhub.auth.oauth.CustomOAuth2UserService;
import com.iflytek.skillhub.auth.oauth.OAuth2LoginFailureHandler;
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.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
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;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@Configuration
@EnableWebSecurity
@ -18,13 +23,16 @@ public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2LoginSuccessHandler successHandler;
private final OAuth2LoginFailureHandler failureHandler;
private final ApiTokenAuthenticationFilter apiTokenAuthenticationFilter;
public SecurityConfig(CustomOAuth2UserService customOAuth2UserService,
OAuth2LoginSuccessHandler successHandler,
OAuth2LoginFailureHandler failureHandler,
ApiTokenAuthenticationFilter apiTokenAuthenticationFilter) {
this.customOAuth2UserService = customOAuth2UserService;
this.successHandler = successHandler;
this.failureHandler = failureHandler;
this.apiTokenAuthenticationFilter = apiTokenAuthenticationFilter;
}
@ -43,6 +51,7 @@ public class SecurityConfig {
.requestMatchers(
"/api/v1/health",
"/api/v1/auth/providers",
"/api/v1/auth/me",
"/api/v1/skills/**",
"/api/v1/namespaces/**",
"/actuator/health",
@ -56,6 +65,16 @@ public class SecurityConfig {
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
.successHandler(successHandler)
.failureHandler(failureHandler)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
.exceptionHandling(exceptions -> exceptions
.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**")
)
)
.logout(logout -> logout
.logoutUrl("/api/v1/auth/logout")

View file

@ -57,6 +57,13 @@ public class IdentityBindingService {
bindingRepo.save(binding);
}
if (user.getStatus() == UserStatus.PENDING) {
throw new com.iflytek.skillhub.auth.oauth.AccountPendingException();
}
if (user.getStatus() == UserStatus.DISABLED) {
throw new com.iflytek.skillhub.auth.oauth.AccountDisabledException();
}
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
.map(rb -> rb.getRole().getCode())
.collect(Collectors.toSet());
@ -66,4 +73,30 @@ public class IdentityBindingService {
user.getAvatarUrl(), claims.provider(), roles
);
}
@Transactional
public void createPendingUserIfAbsent(OAuthClaims claims) {
IdentityBinding existingBinding = bindingRepo
.findByProviderCodeAndSubject(claims.provider(), claims.subject())
.orElse(null);
if (existingBinding != null) {
UserAccount existingUser = userRepo.findById(existingBinding.getUserId())
.orElseThrow(() -> new IllegalStateException("User not found for binding"));
if (existingUser.getStatus() == UserStatus.DISABLED) {
throw new com.iflytek.skillhub.auth.oauth.AccountDisabledException();
}
throw new com.iflytek.skillhub.auth.oauth.AccountPendingException();
}
UserAccount user = new UserAccount(
claims.providerLogin(),
claims.email(),
(String) claims.extra().get("avatar_url")
);
user.setStatus(UserStatus.PENDING);
user = userRepo.save(user);
IdentityBinding binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin());
bindingRepo.save(binding);
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
public class AccountDisabledException extends OAuth2AuthenticationException {
public AccountDisabledException() {
super(new OAuth2Error("account_disabled", "Account is disabled", null));
}
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
public class AccountPendingException extends OAuth2AuthenticationException {
public AccountPendingException() {
super(new OAuth2Error("account_pending", "Account pending approval", null));
}
}

View file

@ -4,6 +4,8 @@ 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 org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
@ -14,6 +16,7 @@ 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.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@ -47,21 +50,28 @@ public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequ
new OAuth2Error("unsupported_provider", "Unsupported: " + registrationId, null));
}
OAuthClaims claims = extractor.extract(oAuth2User);
OAuthClaims claims = extractor.extract(request, oAuth2User);
AccessDecision decision = accessPolicy.evaluate(claims);
UserStatus initialStatus = switch (decision) {
case ALLOW -> UserStatus.ACTIVE;
case PENDING_APPROVAL -> UserStatus.PENDING;
case DENY -> throw new OAuth2AuthenticationException(
if (decision == AccessDecision.PENDING_APPROVAL) {
identityBindingService.createPendingUserIfAbsent(claims);
throw new AccountPendingException();
}
if (decision == AccessDecision.DENY) {
throw new OAuth2AuthenticationException(
new OAuth2Error("access_denied", "Access denied by policy", null));
};
}
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, initialStatus);
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
var attrs = new HashMap<>(oAuth2User.getAttributes());
attrs.put("platformPrincipal", principal);
return new DefaultOAuth2User(oAuth2User.getAuthorities(), attrs, "login");
var authorities = new LinkedHashSet<GrantedAuthority>(oAuth2User.getAuthorities());
principal.platformRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.forEach(authorities::add);
return new DefaultOAuth2User(authorities, attrs, "login");
}
}

View file

@ -1,24 +1,64 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.Comparator;
import java.util.List;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
public class GitHubClaimsExtractor implements OAuthClaimsExtractor {
private final RestClient restClient = RestClient.builder()
.baseUrl("https://api.github.com")
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
@Override
public String getProvider() { return "github"; }
@Override
public OAuthClaims extract(OAuth2User oAuth2User) {
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
GitHubEmail primaryEmail = loadPrimaryEmail(request);
String email = primaryEmail != null ? primaryEmail.email() : (String) attrs.get("email");
boolean emailVerified = primaryEmail != null
? primaryEmail.verified()
: attrs.get("email") != null;
return new OAuthClaims(
"github",
String.valueOf(attrs.get("id")),
(String) attrs.get("email"),
attrs.get("email") != null,
email,
emailVerified,
(String) attrs.get("login"),
attrs
);
}
private GitHubEmail loadPrimaryEmail(OAuth2UserRequest request) {
List<GitHubEmail> emails = restClient.get()
.uri("/user/emails")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<List<GitHubEmail>>() {});
if (emails == null || emails.isEmpty()) {
return null;
}
return emails.stream()
.filter(GitHubEmail::verified)
.sorted(Comparator.comparing(GitHubEmail::primary).reversed())
.findFirst()
.orElse(null);
}
private record GitHubEmail(String email, boolean primary, boolean verified) {}
}

View file

@ -0,0 +1,35 @@
package com.iflytek.skillhub.auth.oauth;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
if (exception instanceof AccountPendingException) {
getRedirectStrategy().sendRedirect(request, response, "/pending-approval");
return;
}
if (exception instanceof AccountDisabledException) {
getRedirectStrategy().sendRedirect(request, response, "/access-denied");
return;
}
if (exception instanceof org.springframework.security.oauth2.core.OAuth2AuthenticationException oauth2Exception
&& "access_denied".equals(oauth2Exception.getError().getErrorCode())) {
getRedirectStrategy().sendRedirect(request, response, "/access-denied");
return;
}
super.onAuthenticationFailure(request, response, exception);
}
}

View file

@ -1,8 +1,9 @@
package com.iflytek.skillhub.auth.oauth;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.user.OAuth2User;
public interface OAuthClaimsExtractor {
String getProvider();
OAuthClaims extract(OAuth2User oAuth2User);
OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User);
}

View file

@ -10,6 +10,7 @@ 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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@ -53,7 +54,10 @@ public class ApiTokenAuthenticationFilter extends OncePerRequestFilter {
user.getId(), user.getDisplayName(), user.getEmail(),
user.getAvatarUrl(), "api_token", roles
);
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of());
var authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
var auth = new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
});
});