Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion server/skillhub-app/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,27 @@ 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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scope 格式不一致

GitHub 已改为 YAML list 格式,但 GitLab 仍用逗号分隔字符串。Spring Security 对逗号分隔 scope 的处理可能因版本而异,建议统一为 list 格式:

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}))
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<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");
}
}
Original file line number Diff line number Diff line change
@@ -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.
*/
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace("/user", "") 存在误替换风险

如果自建 GitLab 的域名或路径中包含 "user"(例如 https://gitlab.usercompany.com/api/v4/user),会被错误替换。

建议改为:

if (userInfoUri.endsWith("/user")) {
    return userInfoUri.substring(0, userInfoUri.length() - "/user".length());
}
return userInfoUri;

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.replace("/user", "");
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) {}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions web/public/github-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions web/public/gitlab-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 29 additions & 3 deletions web/src/features/auth/login-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ interface LoginButtonProps {
returnTo?: string
}

/**
* Returns the appropriate icon for a given OAuth provider.
*/
function OAuthIcon({ provider }: { provider: string }) {
const normalizedProvider = provider.toLowerCase()

// GitLab icon
if (normalizedProvider === 'gitlab') {
return (
<img
src="/gitlab-logo.svg"
alt="GitLab"
className="w-5 h-5 mr-3"
/>
)
}

// GitHub icon (default)
return (
<img
src="/github-logo.svg"
alt="GitHub"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

未知 provider 默认返回 GitHub 图标

当前 fallback 是 GitHub logo,如果后续加入 Gitee 等其他 provider 会显示错误图标。建议 fallback 用一个通用登录图标,或者根据 provider name 动态匹配:

// 通用 fallback
return <LogIn className="w-5 h-5 mr-3" />

或者让后端在 provider 列表中返回 icon URL,前端直接渲染。

className="w-5 h-5 mr-3"
/>
)
}

/**
* Renders OAuth login buttons from the auth-method catalog returned by the backend.
*/
Expand Down Expand Up @@ -37,12 +64,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>
)
}

2 changes: 1 addition & 1 deletion web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@
"submit": "登录",
"noAccount": "还没有账号?",
"register": "立即注册",
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
"enterpriseSsoTitle": "企业单点登录",
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",
Expand Down
Loading