fix(auth): address second CAS review — ticket log, emailVerified, package, CSRF

B1 — Ticket log leak: log.debug now records only the validation path
(resolvedProtocolVersion().validatePath()); the catch block surfaces only
e.getClass().getSimpleName() instead of e.getMessage(), preventing the
full validation URL (with ticket query param) from reaching log streams.

B2 — emailVerified semantics: CasIdentityClaims.emailVerified() now
constantly returns false. CAS passes through email attributes from the
upstream directory (LDAP/AD) without cryptographic verification, so
returning true was a false signal to any AccessPolicy that gates on it.

B3 — Exception package: AccountPendingException and AccountDisabledException
moved from auth.oauth to auth.identity; all import sites updated. OAuth,
CAS, and future SAML/OIDC flows now import from the neutral package.

S6 — Login CSRF via state nonce: login() generates a cryptographically
random 24-byte nonce, stores it in the session under a CAS-specific key
(skillhub.cas.state), and appends state=<nonce> to the service URL that
is sent to the CAS server. callback() validates the incoming state param
against the session value before touching the ticket; a mismatch short-
circuits to redirect:/login?error=invalid_state. The CAS-specific session
key (skillhub.cas.state / skillhub.cas.returnTo) also eliminates the
previous shared-key concurrency hazard with the OAuth flow.
This commit is contained in:
dongmucat 2026-05-28 14:01:05 +08:00
parent ace0cb2dd8
commit 821c7e1333
11 changed files with 205 additions and 57 deletions

View file

@ -23,6 +23,9 @@ public record CasIdentityClaims(
@Override
public boolean emailVerified() {
return email != null && !email.isBlank();
// CAS protocol does not verify email addresses the attribute is passed through from the
// upstream directory (LDAP, AD, etc.) without cryptographic proof. Return false to prevent
// AccessPolicy implementations from trusting unverified claims.
return false;
}
}

View file

@ -1,9 +1,9 @@
package com.iflytek.skillhub.auth.cas;
import com.iflytek.skillhub.auth.identity.AccessDeniedByPolicyException;
import com.iflytek.skillhub.auth.identity.AccountDisabledException;
import com.iflytek.skillhub.auth.identity.AccountPendingException;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
@ -17,10 +17,18 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.util.UriComponentsBuilder;
import java.security.SecureRandom;
import java.util.Base64;
/**
* Handles CAS SSO login flow: redirect to CAS server and callback with ticket validation.
* Delegates access-policy evaluation and principal provisioning to {@link IdentityAuthenticator}
* so the same allow/deny/pending decisions apply to OAuth and CAS uniformly.
*
* CSRF protection: login() stores a random nonce in the session and passes it as the
* {@code state} parameter to the CAS login URL. callback() validates the nonce before
* processing the ticket, preventing attackers from forcing a victim into an authenticated
* session by crafting a callback URL with an attacker-controlled ticket.
*/
@Controller
@RequestMapping("/api/v1/auth/cas")
@ -28,6 +36,11 @@ public class CasLoginController {
private static final Logger log = LoggerFactory.getLogger(CasLoginController.class);
static final String SESSION_CAS_RETURN_TO_ATTRIBUTE = "skillhub.cas.returnTo";
static final String SESSION_CAS_STATE_ATTRIBUTE = "skillhub.cas.state";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final CasProperties casProperties;
private final CasTicketValidator ticketValidator;
private final IdentityAuthenticator identityAuthenticator;
@ -55,24 +68,33 @@ public class CasLoginController {
return "redirect:/login?error=cas_disabled";
}
HttpSession session = request.getSession(true);
String sanitized = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
if (sanitized != null) {
HttpSession session = request.getSession(true);
session.setAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE, sanitized);
session.setAttribute(SESSION_CAS_RETURN_TO_ATTRIBUTE, sanitized);
} else {
session.removeAttribute(SESSION_CAS_RETURN_TO_ATTRIBUTE);
}
String state = generateState();
session.setAttribute(SESSION_CAS_STATE_ATTRIBUTE, state);
String serviceUrl = casProperties.getServiceUrl() + "?state=" + state;
String casLoginUrl = UriComponentsBuilder
.fromHttpUrl(casProperties.getServerUrl() + "/login")
.queryParam("service", casProperties.getServiceUrl())
.queryParam("service", serviceUrl)
.toUriString();
log.debug("Redirecting to CAS login: {}", casLoginUrl);
log.debug("Redirecting to CAS login: endpoint={}", casProperties.getServerUrl());
return "redirect:" + casLoginUrl;
}
@GetMapping("/callback")
public String callback(
@RequestParam(required = false) String ticket,
@RequestParam(required = false) String state,
HttpServletRequest request
) {
if (!casProperties.isEnabled()) {
@ -85,6 +107,15 @@ public class CasLoginController {
return "redirect:/login?error=missing_ticket";
}
// CSRF: validate state nonce before touching the ticket
HttpSession session = request.getSession(false);
if (!isValidState(session, state)) {
log.warn("CAS callback state mismatch — possible CSRF attempt");
return "redirect:/login?error=invalid_state";
}
assert session != null; // guaranteed by isValidState
session.removeAttribute(SESSION_CAS_STATE_ATTRIBUTE);
CasIdentityClaims claims;
try {
claims = ticketValidator.validate(ticket);
@ -99,11 +130,13 @@ public class CasLoginController {
PlatformPrincipal principal = identityAuthenticator.authenticate(claims);
sessionService.establishSession(principal, request);
HttpSession session = request.getSession(false);
// Read returnTo from session AFTER establishSession (which may rotate the session id
// but preserves attributes on the same session object)
HttpSession postAuthSession = request.getSession(false);
String returnTo = null;
if (session != null) {
returnTo = (String) session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
if (postAuthSession != null) {
returnTo = (String) postAuthSession.getAttribute(SESSION_CAS_RETURN_TO_ATTRIBUTE);
postAuthSession.removeAttribute(SESSION_CAS_RETURN_TO_ATTRIBUTE);
}
String targetUrl = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
@ -127,4 +160,18 @@ public class CasLoginController {
return "redirect:/login?error=internal_error";
}
}
private static String generateState() {
byte[] bytes = new byte[24];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static boolean isValidState(HttpSession session, String incomingState) {
if (session == null || incomingState == null || incomingState.isBlank()) {
return false;
}
Object stored = session.getAttribute(SESSION_CAS_STATE_ATTRIBUTE);
return stored instanceof String storedState && storedState.equals(incomingState);
}
}

View file

@ -70,7 +70,7 @@ public class CasTicketValidator {
}
String validationUrl = buildValidationUrl(ticket);
log.debug("Validating CAS ticket at: {}", validationUrl);
log.debug("Validating CAS ticket at endpoint: {}", casProperties.resolvedProtocolVersion().validatePath());
try {
String response = restClient.get()
@ -90,8 +90,12 @@ public class CasTicketValidator {
} catch (CasValidationException e) {
throw e;
} catch (Exception e) {
log.error("CAS ticket validation failed", e);
throw new CasValidationException("Failed to validate CAS ticket: " + e.getMessage(), e);
// Log full exception (including URL) at server level for operators; surface only the
// exception class to callers so the ticket query parameter never reaches downstream
// log streams or error redirects.
log.error("CAS ticket validation request failed", e);
throw new CasValidationException(
"Failed to validate CAS ticket: " + e.getClass().getSimpleName(), e);
}
}

View file

@ -1,10 +1,11 @@
package com.iflytek.skillhub.auth.oauth;
package com.iflytek.skillhub.auth.identity;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
/**
* OAuth authentication exception raised when the mapped platform account is disabled.
* Thrown when an authenticated external identity maps to a platform account that is disabled.
* Used by both OAuth and CAS flows.
*/
public class AccountDisabledException extends OAuth2AuthenticationException {

View file

@ -1,10 +1,11 @@
package com.iflytek.skillhub.auth.oauth;
package com.iflytek.skillhub.auth.identity;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
/**
* OAuth authentication exception raised when the mapped platform account is pending approval.
* Thrown when an authenticated external identity maps to a platform account that is pending approval.
* Used by both OAuth and CAS flows.
*/
public class AccountPendingException extends OAuth2AuthenticationException {

View file

@ -1,6 +1,6 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.identity.AccountPendingException;
import com.iflytek.skillhub.auth.policy.AccessDecision;
import com.iflytek.skillhub.auth.policy.AccessPolicy;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
@ -28,7 +28,7 @@ public class IdentityAuthenticator {
*
* @throws AccountPendingException if the policy yields PENDING_APPROVAL
* @throws AccessDeniedByPolicyException if the policy yields DENY
* @throws com.iflytek.skillhub.auth.oauth.AccountDisabledException if the user is disabled
* @throws com.iflytek.skillhub.auth.identity.AccountDisabledException if the user is disabled
*/
public PlatformPrincipal authenticate(IdentityClaims claims) {
AccessDecision decision = accessPolicy.evaluate(claims);

View file

@ -71,10 +71,10 @@ public class IdentityBindingService {
}
if (user.getStatus() == UserStatus.PENDING) {
throw new com.iflytek.skillhub.auth.oauth.AccountPendingException();
throw new com.iflytek.skillhub.auth.identity.AccountPendingException();
}
if (user.getStatus() == UserStatus.DISABLED) {
throw new com.iflytek.skillhub.auth.oauth.AccountDisabledException();
throw new com.iflytek.skillhub.auth.identity.AccountDisabledException();
}
Set<String> roles = roleBindingRepo.findByUserId(user.getId()).stream()
@ -97,9 +97,9 @@ public class IdentityBindingService {
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.identity.AccountDisabledException();
}
throw new com.iflytek.skillhub.auth.oauth.AccountPendingException();
throw new com.iflytek.skillhub.auth.identity.AccountPendingException();
}
UserAccount user = new UserAccount(

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.AccessDeniedByPolicyException;
import com.iflytek.skillhub.auth.identity.AccountDisabledException;
import com.iflytek.skillhub.auth.identity.AccountPendingException;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;

View file

@ -8,9 +8,9 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.AccessDeniedByPolicyException;
import com.iflytek.skillhub.auth.identity.AccountDisabledException;
import com.iflytek.skillhub.auth.identity.AccountPendingException;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import java.util.Map;
@ -50,6 +50,8 @@ class CasLoginControllerTest {
controller = new CasLoginController(casProperties, ticketValidator, identityAuthenticator, sessionService);
}
// login()
@Test
void login_redirectsToCasServer() {
MockHttpServletRequest request = new MockHttpServletRequest();
@ -61,12 +63,49 @@ class CasLoginControllerTest {
}
@Test
void login_storesReturnToInSession() {
void login_storesCasReturnToInSession() {
MockHttpServletRequest request = new MockHttpServletRequest();
controller.login("/skills", request);
assertThat(request.getSession().getAttribute("skillhub.oauth.returnTo")).isEqualTo("/skills");
assertThat(request.getSession(false))
.isNotNull()
.extracting(s -> s.getAttribute(CasLoginController.SESSION_CAS_RETURN_TO_ATTRIBUTE))
.isEqualTo("/skills");
// Must NOT bleed into the shared OAuth key
assertThat(request.getSession(false)
.getAttribute("skillhub.oauth.returnTo")).isNull();
}
@Test
void login_storesStateNonceInSession() {
MockHttpServletRequest request = new MockHttpServletRequest();
controller.login(null, request);
Object nonce = request.getSession(false)
.getAttribute(CasLoginController.SESSION_CAS_STATE_ATTRIBUTE);
assertThat(nonce).isInstanceOf(String.class);
assertThat((String) nonce).hasSizeGreaterThanOrEqualTo(20);
}
@Test
void login_embedsStateInServiceUrl() {
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.login(null, request);
String nonce = (String) request.getSession(false)
.getAttribute(CasLoginController.SESSION_CAS_STATE_ATTRIBUTE);
// The service URL is percent-encoded when embedded as a query param in the CAS login URL.
// The state param itself may appear percent-encoded (= %3D) or raw depending on how
// UriComponentsBuilder encodes the outer service param. Assert the nonce value appears
// in the redirect URL in either form.
assertThat(result).satisfiesAnyOf(
r -> assertThat(r).contains("state=" + nonce),
r -> assertThat(r).contains("state%3D" + nonce),
r -> assertThat(r).contains("state%3d" + nonce)
);
}
@Test
@ -75,7 +114,8 @@ class CasLoginControllerTest {
controller.login("https://evil.com", request);
assertThat(request.getSession(false)).isNull();
assertThat(request.getSession(false)
.getAttribute(CasLoginController.SESSION_CAS_RETURN_TO_ATTRIBUTE)).isNull();
}
@Test
@ -88,12 +128,22 @@ class CasLoginControllerTest {
assertThat(result).isEqualTo("redirect:/login?error=cas_disabled");
}
@Test
void callback_successfulTicketValidation() {
// callback()
private MockHttpServletRequest requestWithValidState(String returnTo) {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpSession session = new MockHttpSession();
session.setAttribute("skillhub.oauth.returnTo", "/dashboard");
session.setAttribute(CasLoginController.SESSION_CAS_STATE_ATTRIBUTE, "valid-nonce");
if (returnTo != null) {
session.setAttribute(CasLoginController.SESSION_CAS_RETURN_TO_ATTRIBUTE, returnTo);
}
request.setSession(session);
return request;
}
@Test
void callback_successfulTicketValidation() {
MockHttpServletRequest request = requestWithValidState("/dashboard");
CasIdentityClaims claims = new CasIdentityClaims("zhangsan", "zhangsan@example.com", "Zhang San", Map.of());
PlatformPrincipal principal = new PlatformPrincipal("usr_123", "Zhang San", "zhangsan@example.com", null, "cas", Set.of("USER"));
@ -101,7 +151,7 @@ class CasLoginControllerTest {
when(ticketValidator.validate("ST-12345")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenReturn(principal);
String result = controller.callback("ST-12345", request);
String result = controller.callback("ST-12345", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/dashboard");
verify(sessionService).establishSession(eq(principal), eq(request));
@ -109,7 +159,7 @@ class CasLoginControllerTest {
@Test
void callback_usesDefaultTargetWhenNoReturnTo() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
CasIdentityClaims claims = new CasIdentityClaims("user1", null, "User One", Map.of());
PlatformPrincipal principal = new PlatformPrincipal("usr_456", "User One", null, null, "cas", Set.of("USER"));
@ -117,17 +167,14 @@ class CasLoginControllerTest {
when(ticketValidator.validate("ST-99999")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenReturn(principal);
String result = controller.callback("ST-99999", request);
String result = controller.callback("ST-99999", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/dashboard");
}
@Test
void callback_sanitizesUnsafeReturnTo() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpSession session = new MockHttpSession();
session.setAttribute("skillhub.oauth.returnTo", "https://evil.example/steal");
request.setSession(session);
MockHttpServletRequest request = requestWithValidState("https://evil.example/steal");
CasIdentityClaims claims = new CasIdentityClaims("u", null, "U", Map.of());
PlatformPrincipal principal = new PlatformPrincipal("usr_x", "U", null, null, "cas", Set.of("USER"));
@ -135,16 +182,16 @@ class CasLoginControllerTest {
when(ticketValidator.validate("ST-evil")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenReturn(principal);
String result = controller.callback("ST-evil", request);
String result = controller.callback("ST-evil", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/dashboard");
}
@Test
void callback_missingTicket_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
String result = controller.callback(null, request);
String result = controller.callback(null, "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/login?error=missing_ticket");
verify(ticketValidator, never()).validate(any());
@ -152,9 +199,9 @@ class CasLoginControllerTest {
@Test
void callback_blankTicket_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
String result = controller.callback(" ", request);
String result = controller.callback(" ", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/login?error=missing_ticket");
}
@ -162,60 +209,103 @@ class CasLoginControllerTest {
@Test
void callback_whenDisabled_redirectsWithError() {
casProperties.setEnabled(false);
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
String result = controller.callback("ST-12345", request);
String result = controller.callback("ST-12345", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/login?error=cas_disabled");
}
@Test
void callback_accountPending_redirectsToPendingApproval() {
void callback_missingState_rejectsCsrf() {
MockHttpServletRequest request = requestWithValidState(null);
String result = controller.callback("ST-csrf", null, request);
assertThat(result).isEqualTo("redirect:/login?error=invalid_state");
verify(ticketValidator, never()).validate(any());
}
@Test
void callback_wrongState_rejectsCsrf() {
MockHttpServletRequest request = requestWithValidState(null);
String result = controller.callback("ST-csrf", "wrong-nonce", request);
assertThat(result).isEqualTo("redirect:/login?error=invalid_state");
verify(ticketValidator, never()).validate(any());
}
@Test
void callback_noSession_rejectsCsrf() {
MockHttpServletRequest request = new MockHttpServletRequest();
String result = controller.callback("ST-csrf", "any-state", request);
assertThat(result).isEqualTo("redirect:/login?error=invalid_state");
verify(ticketValidator, never()).validate(any());
}
@Test
void callback_accountPending_redirectsToPendingApproval() {
MockHttpServletRequest request = requestWithValidState(null);
CasIdentityClaims claims = new CasIdentityClaims("pending-user", null, "Pending", Map.of());
when(ticketValidator.validate("ST-pending")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenThrow(new AccountPendingException());
String result = controller.callback("ST-pending", request);
String result = controller.callback("ST-pending", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/pending-approval");
}
@Test
void callback_accountDisabled_redirectsToAccessDenied() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
CasIdentityClaims claims = new CasIdentityClaims("disabled-user", null, "Disabled", Map.of());
when(ticketValidator.validate("ST-disabled")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenThrow(new AccountDisabledException());
String result = controller.callback("ST-disabled", request);
String result = controller.callback("ST-disabled", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/access-denied");
}
@Test
void callback_accessPolicyDeny_redirectsToAccessDenied() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
CasIdentityClaims claims = new CasIdentityClaims("denied-user", "denied@bad.example", "Denied", Map.of());
when(ticketValidator.validate("ST-denied")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenThrow(new AccessDeniedByPolicyException());
String result = controller.callback("ST-denied", request);
String result = controller.callback("ST-denied", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/access-denied");
}
@Test
void callback_validationFailed_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletRequest request = requestWithValidState(null);
when(ticketValidator.validate("ST-invalid")).thenThrow(new CasValidationException("Invalid ticket"));
String result = controller.callback("ST-invalid", request);
String result = controller.callback("ST-invalid", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/login?error=cas_validation_failed");
}
@Test
void callback_unexpectedError_redirectsWithInternalError() {
MockHttpServletRequest request = requestWithValidState(null);
CasIdentityClaims claims = new CasIdentityClaims("user", null, "User", Map.of());
when(ticketValidator.validate("ST-broken")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenThrow(new RuntimeException("DB down"));
String result = controller.callback("ST-broken", "valid-nonce", request);
assertThat(result).isEqualTo("redirect:/login?error=internal_error");
}
}

View file

@ -7,7 +7,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.identity.AccountPendingException;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.policy.AccessDecision;
import com.iflytek.skillhub.auth.policy.AccessPolicy;

View file

@ -10,9 +10,9 @@ import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.identity.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.identity.AccountPendingException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;