From d1459aea83a120e98bc72338690ec3c5a2672eeb Mon Sep 17 00:00:00 2001 From: wurongjie Date: Thu, 9 Apr 2026 09:53:04 +0800 Subject: [PATCH] feat(auth): add GitLab OAuth2 provider support Add GitLab as an additional OAuth2 authentication provider alongside GitHub. This includes: - GitLab OAuth2 client configuration with customizable base URL - GitLabClaimsExtractor for handling GitLab-specific user claims - Multi-provider login UI with provider-specific icons - Updated localization to use OAuth-agnostic terminology - JSON type annotation for IdentityBinding entity --- .../src/main/resources/application.yml | 21 ++- .../controller/AuthControllerTest.java | 7 +- .../skillhub/auth/entity/IdentityBinding.java | 15 +- .../auth/oauth/CustomOAuth2UserService.java | 4 +- .../auth/oauth/GitLabClaimsExtractor.java | 138 ++++++++++++++++++ .../auth/session/PlatformSessionService.java | 14 +- web/public/github-logo.svg | 2 + web/public/gitlab-logo.svg | 2 + web/src/features/auth/login-button.tsx | 19 ++- web/src/i18n/locales/en.json | 2 +- web/src/i18n/locales/zh.json | 2 +- 11 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java create mode 100644 web/public/github-logo.svg create mode 100644 web/public/gitlab-logo.svg diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 8a3872afd..dbd3bab82 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -53,10 +53,29 @@ spring: github: client-id: ${OAUTH2_GITHUB_CLIENT_ID:placeholder} client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:placeholder} - scope: read:user,user:email + scope: + - read:user + - user:email + redirect-uri: ${SKILLHUB_PUBLIC_BASE_URL:http://localhost:8080}/login/oauth2/code/github + client-name: GitHub + authorization-grant-type: authorization_code + gitlab: + client-id: ${OAUTH2_GITLAB_CLIENT_ID:placeholder} + client-secret: ${OAUTH2_GITLAB_CLIENT_SECRET:placeholder} + scope: + - read_user + - email + authorization-grant-type: authorization_code + redirect-uri: ${SKILLHUB_PUBLIC_BASE_URL:http://localhost:8080}/login/oauth2/code/gitlab + client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab} provider: github: user-info-uri: https://api.github.com/user + gitlab: + authorization-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/authorize + token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token + user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user + user-name-attribute: username servlet: multipart: max-file-size: 100MB diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java index 47e77a314..a25d31048 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AuthControllerTest.java @@ -142,11 +142,12 @@ void providersShouldExposeGithubLoginEntry() throws Exception { mockMvc.perform(get("/api/v1/auth/providers")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.length()").value(2)) - .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee"))) + .andExpect(jsonPath("$.data.length()").value(3)) + .andExpect(jsonPath("$.data[*].id", hasItems("github", "gitee", "gitlab"))) .andExpect(jsonPath("$.data[*].authorizationUrl", hasItems( "/oauth2/authorization/github", - "/oauth2/authorization/gitee" + "/oauth2/authorization/gitee", + "/oauth2/authorization/gitlab" ))) .andExpect(jsonPath("$.timestamp").isNotEmpty()) .andExpect(jsonPath("$.requestId").isNotEmpty()); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java index 163694f4c..6e28aa683 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -1,9 +1,21 @@ package com.iflytek.skillhub.auth.entity; -import jakarta.persistence.*; import java.time.Clock; import java.time.Instant; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + @Entity @Table(name = "identity_binding", uniqueConstraints = @UniqueConstraint(columnNames = {"provider_code", "subject"})) @@ -24,6 +36,7 @@ public class IdentityBinding { @Column(name = "login_name", length = 128) private String loginName; + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "extra_json", columnDefinition = "jsonb") private String extraJson; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java index c578baa3d..20ca3fd68 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/CustomOAuth2UserService.java @@ -31,12 +31,14 @@ public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2Authenticatio PlatformPrincipal principal = context.principal(); var attrs = new HashMap<>(context.upstreamUser().getAttributes()); attrs.put("platformPrincipal", principal); + // Store providerLogin under a fixed key so DefaultOAuth2User can find it + attrs.put("providerLogin", principal.userId()); var authorities = new LinkedHashSet(context.upstreamUser().getAuthorities()); principal.platformRoles().stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .forEach(authorities::add); - return new DefaultOAuth2User(authorities, attrs, "login"); + return new DefaultOAuth2User(authorities, attrs, "providerLogin"); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java new file mode 100644 index 000000000..953c5cc0a --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java @@ -0,0 +1,138 @@ +package com.iflytek.skillhub.auth.oauth; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.List; +import java.util.Map; + +/** + * Provider-specific claims extractor that enriches GitLab OAuth users with their + * verified email information. + * + *

GitLab OAuth2 user info endpoint returns user profile data. This extractor + * fetches additional email information from GitLab API when needed. + */ +@Component +public class GitLabClaimsExtractor implements OAuthClaimsExtractor { + + private static final Logger log = LoggerFactory.getLogger(GitLabClaimsExtractor.class); + + private final RestClient restClient; + + public GitLabClaimsExtractor() { + this.restClient = RestClient.builder() + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Override + public String getProvider() { + return "gitlab"; + } + + @Override + public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) { + Map attrs = oAuth2User.getAttributes(); + log.debug("Extracting GitLab OAuth claims for user attributes: {}", attrs.keySet()); + + // GitLab returns email directly in user info + String email = (String) attrs.get("email"); + + // GitLab provides email_verified field in the user info response + Boolean emailVerifiedObj = (Boolean) attrs.get("email_verified"); + boolean emailVerified = emailVerifiedObj != null && emailVerifiedObj; + + log.debug("Initial email from GitLab: {}, verified: {}", email, emailVerified); + + // If email is not verified or not present, try to fetch from emails API + if (email == null || !emailVerified) { + log.debug("Email not verified or missing, attempting to fetch from GitLab emails API"); + GitLabEmail primaryEmail = loadPrimaryEmail(request); + if (primaryEmail != null) { + email = primaryEmail.email(); + emailVerified = true; + log.debug("Found verified email from GitLab API: {}", email); + } else { + log.debug("No verified email found from GitLab emails API"); + } + } + + // GitLab uses "username" for login name + String username = (String) attrs.get("username"); + if (username == null) { + username = (String) attrs.get("login"); + } + + String subject = String.valueOf(attrs.get("id")); + log.info("GitLab OAuth claims extracted - subject: {}, username: {}, email: {}, emailVerified: {}", + subject, username, email, emailVerified); + + return new OAuthClaims( + "gitlab", + subject, + email, + emailVerified, + username, + attrs + ); + } + + private GitLabEmail loadPrimaryEmail(OAuth2UserRequest request) { + String baseUrl = getGitLabApiBaseUrl(request); + log.debug("Loading primary email from GitLab API base URL: {}", baseUrl); + + try { + List emails = restClient.get() + .uri(baseUrl + "/user/emails") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue()) + .retrieve() + .body(new org.springframework.core.ParameterizedTypeReference>() {}); + + if (emails == null || emails.isEmpty()) { + log.debug("No emails returned from GitLab emails API"); + return null; + } + + log.debug("Retrieved {} emails from GitLab API", emails.size()); + + // Return the primary verified email + return emails.stream() + .filter(GitLabEmail::confirmed) + .findFirst() + .orElse(null); + } catch (Exception e) { + log.warn("Failed to fetch emails from GitLab API: {}", e.getMessage()); + return null; + } + } + + /** + * Determines the GitLab API base URL from the provider configuration. + * The user-info-uri is configured as ${OAUTH2_GITLAB_BASE_URI}/api/v4/user, + * so we simply remove the /user suffix to get the API base URL. + */ + private String getGitLabApiBaseUrl(OAuth2UserRequest request) { + String userInfoUri = request.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri(); + log.debug("GitLab user info URI: {}", userInfoUri); + // user-info-uri format: ${OAUTH2_GITLAB_BASE_URI}/api/v4/user + // Remove /user suffix to get API base URL + String baseUrl = userInfoUri.substring(0, userInfoUri.length() - "/user".length()); + log.debug("GitLab API base URL: {}", baseUrl); + return baseUrl; + } + + /** + * Represents a GitLab email object from the /user/emails API. + * + * @param email the email address + * @param confirmed whether the email has been confirmed + */ + private record GitLabEmail(String email, boolean confirmed) {} +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java index 3908ab713..4b811d36c 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/PlatformSessionService.java @@ -1,7 +1,5 @@ package com.iflytek.skillhub.auth.session; -import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -10,6 +8,10 @@ import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Service; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; + +import jakarta.servlet.http.HttpServletRequest; + /** * Synchronizes {@link PlatformPrincipal} snapshots with Spring Security's * session-backed authentication context. @@ -53,7 +55,13 @@ public void attachToAuthenticatedSession(PlatformPrincipal principal, Authentication authentication, HttpServletRequest request, boolean rotateSessionId) { - persist(principal, authentication, request, rotateSessionId); + // Create a new authentication with PlatformPrincipal as the principal + // instead of using the OAuth2 authentication which has OAuth2User as principal + var authorities = principal.platformRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + Authentication platformAuth = new UsernamePasswordAuthenticationToken(principal, null, authorities); + persist(principal, platformAuth, request, rotateSessionId); } private void persist(PlatformPrincipal principal, diff --git a/web/public/github-logo.svg b/web/public/github-logo.svg new file mode 100644 index 000000000..a11334011 --- /dev/null +++ b/web/public/github-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/public/gitlab-logo.svg b/web/public/gitlab-logo.svg new file mode 100644 index 000000000..71a666d04 --- /dev/null +++ b/web/public/gitlab-logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/src/features/auth/login-button.tsx b/web/src/features/auth/login-button.tsx index ba0bc040f..cde4453f0 100644 --- a/web/src/features/auth/login-button.tsx +++ b/web/src/features/auth/login-button.tsx @@ -6,6 +6,20 @@ interface LoginButtonProps { returnTo?: string } +/** + * Returns the appropriate icon for a given OAuth provider. + */ +function OAuthIcon({ provider }: { provider: string }) { + const normalizedProvider = provider.toLowerCase() + return ( + {provider} + ) +} + /** * Renders OAuth login buttons from the auth-method catalog returned by the backend. */ @@ -37,12 +51,11 @@ export function LoginButton({ returnTo }: LoginButtonProps) { window.location.href = provider.actionUrl }} > - - - + {t('loginButton.loginWith', { name: provider.displayName })} ))} ) } + diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b1e35669d..e0faae42d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -211,7 +211,7 @@ "submit": "Login", "noAccount": "Don't have an account?", "register": "Sign up now", - "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", + "oauthHint": "After OAuth authentication, you will be automatically redirected back to this site.", "passwordCompatHint": "This deployment has the password compatibility layer enabled. The form will route to {{name}} instead of the fixed local account endpoint.", "enterpriseSsoTitle": "Enterprise SSO", "enterpriseSsoHint": "This deployment has the compatibility layer enabled. If your browser already has a {{name}} session, you can try establishing a SkillHub session directly.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 41972c554..39be514be 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -211,7 +211,7 @@ "submit": "登录", "noAccount": "还没有账号?", "register": "立即注册", - "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", + "oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。", "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。", "enterpriseSsoTitle": "企业单点登录", "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",