From c03790a11e2edb01e01c7a37f1bc76b6e6fddd4e Mon Sep 17 00:00:00 2001 From: wowo Date: Fri, 17 Apr 2026 17:45:34 +0800 Subject: [PATCH 1/4] Fix runtime Postgres password drift (#321) - start postgres before bringing up application services - sync the database role password from .env.release - verify TCP auth with the synced password before startup --- scripts/runtime.sh | 59 ++++++++++++++++++++++++++ scripts/skillhub-test-deploy-remote.sh | 59 ++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/scripts/runtime.sh b/scripts/runtime.sh index 20c2dd58..f5b89550 100755 --- a/scripts/runtime.sh +++ b/scripts/runtime.sh @@ -168,6 +168,63 @@ set_env_value() { mv "$tmp" "$ENV_FILE" } +get_env_value() { + key="$1" + default_value="${2:-}" + value="$(grep "^$key=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- || true)" + + if [ -n "$value" ]; then + printf '%s' "$value" + else + printf '%s' "$default_value" + fi +} + +wait_for_postgres_ready() { + postgres_user="$1" + postgres_db="$2" + attempt=1 + + while [ "$attempt" -le 60 ]; do + if run_compose exec -T postgres pg_isready -U "$postgres_user" -d "$postgres_db" >/dev/null 2>&1; then + return 0 + fi + + attempt=$((attempt + 1)) + sleep 2 + done + + echo "PostgreSQL did not become ready in time." >&2 + run_compose logs postgres >&2 || true + exit 1 +} + +ensure_postgres_password_matches_env() { + postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")" + postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")" + postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")" + + if [ -z "$postgres_password" ]; then + echo "POSTGRES_PASSWORD must not be empty." >&2 + exit 1 + fi + + wait_for_postgres_ready "$postgres_user" "$postgres_db" + + run_compose exec -T postgres \ + psql -U "$postgres_user" -d "$postgres_db" \ + -v ON_ERROR_STOP=1 \ + -v password="$postgres_password" <<'SQL' >/dev/null +SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password'); +\gexec +SQL + + run_compose exec -T -e "PGPASSWORD=$postgres_password" postgres \ + psql -h 127.0.0.1 -U "$postgres_user" -d "$postgres_db" \ + -v ON_ERROR_STOP=1 \ + -c 'select current_user;' >/dev/null +} + prepare_runtime_files() { mkdir -p "$SKILLHUB_HOME" download_file "$SKILLHUB_RAW_BASE/compose.release.yml" "$COMPOSE_FILE" @@ -235,6 +292,8 @@ prepare_runtime_files case "$COMMAND" in up) + run_compose up -d postgres + ensure_postgres_password_matches_env if [ "$DISABLE_SCANNER" = "true" ]; then SKILLHUB_SECURITY_SCANNER_ENABLED=false run_compose up -d --scale skill-scanner=0 else diff --git a/scripts/skillhub-test-deploy-remote.sh b/scripts/skillhub-test-deploy-remote.sh index 39663624..08dde944 100644 --- a/scripts/skillhub-test-deploy-remote.sh +++ b/scripts/skillhub-test-deploy-remote.sh @@ -104,6 +104,63 @@ set_env_value() { mv "${tmp}" .env.release } +get_env_value() { + key="$1" + default_value="${2:-}" + value="$(grep -E "^${key}=" .env.release | tail -n 1 | cut -d= -f2- || true)" + + if [[ -n "${value}" ]]; then + printf '%s' "${value}" + else + printf '%s' "${default_value}" + fi +} + +wait_for_postgres_ready() { + postgres_user="$1" + postgres_db="$2" + + for attempt in $(seq 1 60); do + if docker compose --env-file .env.release -f compose.release.yml exec -T postgres \ + pg_isready -U "${postgres_user}" -d "${postgres_db}" >/dev/null 2>&1; then + return 0 + fi + + sleep 2 + done + + echo "PostgreSQL did not become ready in time" >&2 + docker compose --env-file .env.release -f compose.release.yml logs postgres >&2 || true + exit 1 +} + +ensure_postgres_password_matches_env() { + postgres_user="$(get_env_value "POSTGRES_USER" "skillhub")" + postgres_db="$(get_env_value "POSTGRES_DB" "skillhub")" + postgres_password="$(get_env_value "POSTGRES_PASSWORD" "skillhub_demo")" + + if [[ -z "${postgres_password}" ]]; then + echo "POSTGRES_PASSWORD must not be empty" >&2 + exit 1 + fi + + wait_for_postgres_ready "${postgres_user}" "${postgres_db}" + + docker compose --env-file .env.release -f compose.release.yml exec -T postgres \ + psql -U "${postgres_user}" -d "${postgres_db}" \ + -v ON_ERROR_STOP=1 \ + -v password="${postgres_password}" <<'SQL' >/dev/null +SELECT format('ALTER ROLE %I WITH PASSWORD %L', current_user, :'password'); +\gexec +SQL + + docker compose --env-file .env.release -f compose.release.yml exec -T \ + -e PGPASSWORD="${postgres_password}" postgres \ + psql -h 127.0.0.1 -U "${postgres_user}" -d "${postgres_db}" \ + -v ON_ERROR_STOP=1 \ + -c 'select current_user;' >/dev/null +} + cd "${runtime_dir}" test -f .env.release @@ -123,6 +180,8 @@ run_url=${run_url} METADATA docker compose --env-file .env.release -f compose.release.yml pull +docker compose --env-file .env.release -f compose.release.yml up -d postgres +ensure_postgres_password_matches_env docker compose --env-file .env.release -f compose.release.yml up -d docker compose --env-file .env.release -f compose.release.yml ps From 080be28eefcf2c1ee5a942d83588f2dbbc456d07 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:46:05 +0800 Subject: [PATCH 2/4] fix(search): align portal visibility for private skills (#323) * feat(ci): add AI-powered release notes generation - Add GitHub Models integration for automated release notes - Support bilingual (EN) release notes with highlights extraction - Fallback to conventional commit grouping when LLM unavailable - Trigger on tag push or manual workflow dispatch - Zero configuration: uses GitHub Models (gpt-4o-mini) by default * chore: pin action versions and update gitignore - Pin checkout and setup-deno to commit hashes matching project convention - Add .playwright-mcp/ and .mcp.json to gitignore * fix(search): keep private skills out of portal discovery - remove super admin expanded visibility from portal search - exclude all private skills from portal search results - keep namespace-only skills visible only to namespace members - reserve private skill access for my-skills and future admin surfaces * test(search): update test to reflect portal visibility policy - rename test from platformWideAccessShouldBypass... to platformWideAccessShouldNotBypass... - verify that platformWideAccess flag does not grant extra visibility in portal search - verify PRIVATE skills are excluded from search SQL - aligns test with new requirement: portal search uses standard visibility rules * test(search): update app service test for portal visibility policy - rename test from search_shouldGrantPlatformWideAccessToSuperAdmin to search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal - verify super admin does not get platformWideAccess in portal search - aligns with requirement: portal uses standard visibility, admin features reserved for dedicated interface --- .../skillhub/service/SkillSearchAppService.java | 6 ++---- .../skillhub/service/SkillSearchAppServiceTest.java | 4 ++-- .../postgres/PostgresFullTextQueryService.java | 9 --------- .../postgres/PostgresFullTextQueryServiceTest.java | 12 ++++++------ 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 57a1145a..820980bb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -122,10 +122,8 @@ public class SkillSearchAppService { } private boolean hasPlatformWideReadAccess(Set platformRoles) { - if (platformRoles == null || platformRoles.isEmpty()) { - return false; - } - return platformRoles.contains("SUPER_ADMIN"); + // Super admins should use a dedicated admin interface, not the public portal + return false; } private SearchResponse searchVisibleSkills( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java index 57710c88..fdac46f8 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSearchAppServiceTest.java @@ -216,7 +216,7 @@ class SkillSearchAppServiceTest { } @Test - void search_shouldGrantPlatformWideAccessToSuperAdmin() { + void search_shouldNotGrantPlatformWideAccessToSuperAdminInPortal() { when(searchQueryService.search(any())) .thenReturn(new SearchResult(List.of(), 0, 0, 20)); when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER")); @@ -228,7 +228,7 @@ class SkillSearchAppServiceTest { SearchVisibilityScope scope = captor.getValue().visibilityScope(); assertEquals("admin-1", scope.userId()); - assertEquals(true, scope.platformWideAccess()); + assertEquals(false, scope.platformWideAccess()); } private void setField(Object target, String fieldName, Object value) { diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index e6888827..ec0cfa37 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { Set adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().adminNamespaceIds(); - boolean platformWideAccess = query.visibilityScope().platformWideAccess(); StringBuilder sql = new StringBuilder(); sql.append("SELECT d.skill_id "); @@ -118,9 +117,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND (d.visibility = 'PUBLIC' "); if (query.visibilityScope().userId() != null) { sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) "); - sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE) "); - sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) "); - sql.append("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE) "); } sql.append(") "); @@ -131,7 +127,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("AND (n.status <> 'ARCHIVED' "); if (query.visibilityScope().userId() != null) { sql.append("OR d.namespace_id IN :memberNamespaceIds "); - sql.append("OR :platformWideAccess = TRUE "); } sql.append(") "); @@ -196,8 +191,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds); nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds); - nativeQuery.setParameter("platformWideAccess", platformWideAccess); - nativeQuery.setParameter("userId", query.visibilityScope().userId()); } if (query.namespaceId() != null) { @@ -243,8 +236,6 @@ public class PostgresFullTextQueryService implements SearchQueryService { if (query.visibilityScope().userId() != null) { countQuery.setParameter("memberNamespaceIds", memberNamespaceIds); countQuery.setParameter("adminNamespaceIds", adminNamespaceIds); - countQuery.setParameter("platformWideAccess", platformWideAccess); - countQuery.setParameter("userId", query.visibilityScope().userId()); } if (query.namespaceId() != null) { diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java index ea33092b..0025f2ec 100644 --- a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -393,7 +393,7 @@ class PostgresFullTextQueryServiceTest { } @Test - void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() { + void platformWideAccessShouldNotBypassVisibilityInPortalSearch() { EntityManager entityManager = mock(EntityManager.class); Query nativeQuery = mock(Query.class); Query countQuery = mock(Query.class); @@ -418,12 +418,12 @@ class PostgresFullTextQueryServiceTest { ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + // Portal search should not include platformWideAccess bypass logic assertThat(sqlCaptor.getAllValues().getFirst()) - .contains("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE)") - .contains("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE)") - .contains("OR :platformWideAccess = TRUE"); - verify(nativeQuery).setParameter("platformWideAccess", true); - verify(countQuery).setParameter("platformWideAccess", true); + .doesNotContain("platformWideAccess") + .doesNotContain("PRIVATE"); + verify(nativeQuery, never()).setParameter("platformWideAccess", true); + verify(countQuery, never()).setParameter("platformWideAccess", true); } @Test From 7be6a36960b7e49c0049b4022b710768e437d603 Mon Sep 17 00:00:00 2001 From: wrj97 Date: Fri, 17 Apr 2026 19:56:32 +0800 Subject: [PATCH 3/4] feat(auth): add GitLab OAuth2 provider support (#264) * 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 * fix(auth): restore oauth redirect and gitlab email checks * test(auth): align oauth login handler expectation --------- Co-authored-by: wowo-zZ --- .../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 | 142 ++++++++++++++++++ .../auth/session/PlatformSessionService.java | 14 +- .../auth/oauth/GitLabClaimsExtractorTest.java | 103 +++++++++++++ .../auth/oauth/OAuth2LoginHandlersTest.java | 2 +- 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 +- 13 files changed, 320 insertions(+), 15 deletions(-) create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.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 0cee466c..0cc32ec4 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: "{baseUrl}/login/oauth2/code/{registrationId}" + 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: "{baseUrl}/login/oauth2/code/{registrationId}" + 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 47e77a31..a25d3104 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 @@ 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()); 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 163694f4..6e28aa68 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 c578baa3..20ca3fd6 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 class CustomOAuth2UserService implements OAuth2UserService(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 00000000..d6c840a2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractor.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.auth.oauth; + +import com.fasterxml.jackson.annotation.JsonProperty; +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(RestClient.Builder restClientBuilder) { + this.restClient = restClientBuilder + .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"); + + boolean emailVerified = isConfirmed(attrs.get("confirmed_at")); + + 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; + } + + /** + * GitLab marks a confirmed email by populating confirmed_at. + */ + private record GitLabEmail(String email, @JsonProperty("confirmed_at") String confirmedAt) { + boolean confirmed() { + return confirmedAt != null && !confirmedAt.isBlank(); + } + } + + private boolean isConfirmed(Object confirmedAt) { + return confirmedAt instanceof String value && !value.isBlank(); + } +} 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 3908ab71..4b811d36 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.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, diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java new file mode 100644 index 00000000..643be583 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/GitLabClaimsExtractorTest.java @@ -0,0 +1,103 @@ +package com.iflytek.skillhub.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class GitLabClaimsExtractorTest { + + @Test + void extract_marksProfileEmailVerifiedWhenConfirmedAtPresent() { + RestClient.Builder restClientBuilder = RestClient.builder(); + GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); + + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "id", 42, + "username", "alice", + "email", "alice@gitlab.example", + "confirmed_at", "2026-04-16T08:00:00Z" + ), + "username" + ) + ); + + assertThat(claims.email()).isEqualTo("alice@gitlab.example"); + assertThat(claims.emailVerified()).isTrue(); + assertThat(claims.providerLogin()).isEqualTo("alice"); + } + + @Test + void extract_loadsConfirmedEmailFromEmailListWhenProfileEmailIsUnconfirmed() { + RestClient.Builder restClientBuilder = RestClient.builder(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build(); + server.expect(requestTo("https://gitlab.example.com/api/v4/user/emails")) + .andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123")) + .andRespond(withSuccess( + """ + [ + {"email":"alice@gitlab.example","confirmed_at":"2026-04-16T08:00:00Z"}, + {"email":"alice+pending@gitlab.example","confirmed_at":null} + ] + """, + MediaType.APPLICATION_JSON + )); + GitLabClaimsExtractor extractor = new GitLabClaimsExtractor(restClientBuilder); + + OAuthClaims claims = extractor.extract( + userRequest(), + new DefaultOAuth2User( + java.util.List.of(), + Map.of( + "id", 42, + "username", "alice", + "email", "alice+pending@gitlab.example" + ), + "username" + ) + ); + + assertThat(claims.email()).isEqualTo("alice@gitlab.example"); + assertThat(claims.emailVerified()).isTrue(); + server.verify(); + } + + private OAuth2UserRequest userRequest() { + ClientRegistration registration = ClientRegistration.withRegistrationId("gitlab") + .clientId("client-id") + .clientSecret("client-secret") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("read_user", "email") + .authorizationUri("https://gitlab.example.com/oauth/authorize") + .tokenUri("https://gitlab.example.com/oauth/token") + .userInfoUri("https://gitlab.example.com/api/v4/user") + .userNameAttributeName("username") + .clientName("GitLab") + .build(); + OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + "token-123", + Instant.now(), + Instant.now().plusSeconds(3600) + ); + return new OAuth2UserRequest(registration, accessToken); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java index 6bf8c098..cb8ef84c 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuth2LoginHandlersTest.java @@ -61,7 +61,7 @@ class OAuth2LoginHandlersTest { assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull(); assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal); assertThat(securityContext).isNotNull(); - assertThat(securityContext.getAuthentication()).isSameAs(authentication); + assertThat(securityContext.getAuthentication().getPrincipal()).isEqualTo(principal); } @Test diff --git a/web/public/github-logo.svg b/web/public/github-logo.svg new file mode 100644 index 00000000..a1133401 --- /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 00000000..71a666d0 --- /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 ba0bc040..cde4453f 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 b0cec8d4..c96495f9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -212,7 +212,7 @@ "forgotPassword": "Forgot password?", "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 0e49a24f..12219285 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -212,7 +212,7 @@ "forgotPassword": "忘记密码?", "noAccount": "还没有账号?", "register": "立即注册", - "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", + "oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。", "passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。", "enterpriseSsoTitle": "企业单点登录", "enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。", From 1d0d6be816b60ab9dcce23e5c26895a320f62d19 Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Mon, 20 Apr 2026 13:54:41 +0800 Subject: [PATCH 4/4] fix(env): add gitlab oauth release example config --- .env.release.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.release.example b/.env.release.example index 9b863c23..3f020fc7 100644 --- a/.env.release.example +++ b/.env.release.example @@ -56,6 +56,13 @@ DEVICE_AUTH_VERIFICATION_URI= OAUTH2_GITHUB_CLIENT_ID= OAUTH2_GITHUB_CLIENT_SECRET= +# Optional: configure real GitLab OAuth before exposing the stack to other users. +# Set OAUTH2_GITLAB_BASE_URI to your self-hosted GitLab URL when applicable. +OAUTH2_GITLAB_CLIENT_ID= +OAUTH2_GITLAB_CLIENT_SECRET= +OAUTH2_GITLAB_BASE_URI=https://gitlab.com +OAUTH2_GITLAB_DISPLAY_NAME=GitLab + # SMTP configuration for password reset verification emails. SPRING_MAIL_HOST= SPRING_MAIL_PORT=587