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
This commit is contained in:
wurongjie 2026-04-09 09:53:04 +08:00
parent 010c1a4e46
commit d1459aea83
11 changed files with 212 additions and 14 deletions

View file

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

View file

@ -142,11 +142,12 @@ class AuthControllerTest {
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());

View file

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

View file

@ -31,12 +31,14 @@ public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequ
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<GrantedAuthority>(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");
}
}

View file

@ -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.
*
* <p>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<String, Object> 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<GitLabEmail> emails = restClient.get()
.uri(baseUrl + "/user/emails")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + request.getAccessToken().getTokenValue())
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<List<GitLabEmail>>() {});
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) {}
}

View file

@ -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.core.context.SecurityContextHolder;
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 class PlatformSessionService {
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,

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 -0.5 25 25" xmlns="http://www.w3.org/2000/svg"><path d="m12.301 0h.093c2.242 0 4.34.613 6.137 1.68l-.055-.031c1.871 1.094 3.386 2.609 4.449 4.422l.031.058c1.04 1.769 1.654 3.896 1.654 6.166 0 5.406-3.483 10-8.327 11.658l-.087.026c-.063.02-.135.031-.209.031-.162 0-.312-.054-.433-.144l.002.001c-.128-.115-.208-.281-.208-.466 0-.005 0-.01 0-.014v.001q0-.048.008-1.226t.008-2.154c.007-.075.011-.161.011-.249 0-.792-.323-1.508-.844-2.025.618-.061 1.176-.163 1.718-.305l-.076.017c.573-.16 1.073-.373 1.537-.642l-.031.017c.508-.28.938-.636 1.292-1.058l.006-.007c.372-.476.663-1.036.84-1.645l.009-.035c.209-.683.329-1.468.329-2.281 0-.045 0-.091-.001-.136v.007c0-.022.001-.047.001-.072 0-1.248-.482-2.383-1.269-3.23l.003.003c.168-.44.265-.948.265-1.479 0-.649-.145-1.263-.404-1.814l.011.026c-.115-.022-.246-.035-.381-.035-.334 0-.649.078-.929.216l.012-.005c-.568.21-1.054.448-1.512.726l.038-.022-.609.384c-.922-.264-1.981-.416-3.075-.416s-2.153.152-3.157.436l.081-.02q-.256-.176-.681-.433c-.373-.214-.814-.421-1.272-.595l-.066-.022c-.293-.154-.64-.244-1.009-.244-.124 0-.246.01-.364.03l.013-.002c-.248.524-.393 1.139-.393 1.788 0 .531.097 1.04.275 1.509l-.01-.029c-.785.844-1.266 1.979-1.266 3.227 0 .025 0 .051.001.076v-.004c-.001.039-.001.084-.001.13 0 .809.12 1.591.344 2.327l-.015-.057c.189.643.476 1.202.85 1.693l-.009-.013c.354.435.782.793 1.267 1.062l.022.011c.432.252.933.465 1.46.614l.046.011c.466.125 1.024.227 1.595.284l.046.004c-.431.428-.718 1-.784 1.638l-.001.012c-.207.101-.448.183-.699.236l-.021.004c-.256.051-.549.08-.85.08-.022 0-.044 0-.066 0h.003c-.394-.008-.756-.136-1.055-.348l.006.004c-.371-.259-.671-.595-.881-.986l-.007-.015c-.198-.336-.459-.614-.768-.827l-.009-.006c-.225-.169-.49-.301-.776-.38l-.016-.004-.32-.048c-.023-.002-.05-.003-.077-.003-.14 0-.273.028-.394.077l.007-.003q-.128.072-.08.184c.039.086.087.16.145.225l-.001-.001c.061.072.13.135.205.19l.003.002.112.08c.283.148.516.354.693.603l.004.006c.191.237.359.505.494.792l.01.024.16.368c.135.402.38.738.7.981l.005.004c.3.234.662.402 1.057.478l.016.002c.33.064.714.104 1.106.112h.007c.045.002.097.002.15.002.261 0 .517-.021.767-.062l-.027.004.368-.064q0 .609.008 1.418t.008.873v.014c0 .185-.08.351-.208.466h-.001c-.119.089-.268.143-.431.143-.075 0-.147-.011-.214-.032l.005.001c-4.929-1.689-8.409-6.283-8.409-11.69 0-2.268.612-4.393 1.681-6.219l-.032.058c1.094-1.871 2.609-3.386 4.422-4.449l.058-.031c1.739-1.034 3.835-1.645 6.073-1.645h.098-.005zm-7.64 17.666q.048-.112-.112-.192-.16-.048-.208.032-.048.112.112.192.144.096.208-.032zm.497.545q.112-.08-.032-.256-.16-.144-.256-.048-.112.08.032.256.159.157.256.047zm.48.72q.144-.112 0-.304-.128-.208-.272-.096-.144.08 0 .288t.272.112zm.672.673q.128-.128-.064-.304-.192-.192-.32-.048-.144.128.064.304.192.192.32.044zm.913.4q.048-.176-.208-.256-.24-.064-.304.112t.208.24q.24.097.304-.096zm1.009.08q0-.208-.272-.176-.256 0-.256.176 0 .208.272.176.256.001.256-.175zm.929-.16q-.032-.176-.288-.144-.256.048-.224.24t.288.128.225-.224z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#FC6D26" d="M14.975 8.904L14.19 6.55l-1.552-4.67a.268.268 0 00-.255-.18.268.268 0 00-.254.18l-1.552 4.667H5.422L3.87 1.879a.267.267 0 00-.254-.179.267.267 0 00-.254.18l-1.55 4.667-.784 2.357a.515.515 0 00.193.583l6.78 4.812 6.778-4.812a.516.516 0 00.196-.583z"/><path fill="#E24329" d="M8 14.296l2.578-7.75H5.423L8 14.296z"/><path fill="#FC6D26" d="M8 14.296l-2.579-7.75H1.813L8 14.296z"/><path fill="#FCA326" d="M1.81 6.549l-.784 2.354a.515.515 0 00.193.583L8 14.3 1.81 6.55z"/><path fill="#E24329" d="M1.812 6.549h3.612L3.87 1.882a.268.268 0 00-.254-.18.268.268 0 00-.255.18L1.812 6.549z"/><path fill="#FC6D26" d="M8 14.296l2.578-7.75h3.614L8 14.296z"/><path fill="#FCA326" d="M14.19 6.549l.783 2.354a.514.514 0 01-.193.583L8 14.296l6.188-7.747h.001z"/><path fill="#E24329" d="M14.19 6.549H10.58l1.551-4.667a.267.267 0 01.255-.18c.115 0 .217.073.254.18l1.552 4.667z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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 (
<img
src={`/${normalizedProvider}-logo.svg`}
alt={provider}
className="w-5 h-5 mr-3"
/>
)
}
/**
* 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
}}
>
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
<OAuthIcon provider={provider.provider} />
{t('loginButton.loginWith', { name: provider.displayName })}
</Button>
))}
</div>
)
}

View file

@ -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.",

View file

@ -211,7 +211,7 @@
"submit": "登录",
"noAccount": "还没有账号?",
"register": "立即注册",
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
"enterpriseSsoTitle": "企业单点登录",
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",