mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Fix phase1 auth flow gaps
This commit is contained in:
parent
e6956e79fc
commit
404692d671
15 changed files with 291 additions and 17 deletions
32
deploy/skillhub-ingress.yaml
Normal file
32
deploy/skillhub-ingress.yaml
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
mock-maker-subclass
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue