mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-14 23:21:08 +00:00
Merge branch 'main' of github.com:iflytek/skillhub
This commit is contained in:
commit
9ced6bb94d
20 changed files with 455 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -122,10 +122,8 @@ public class SkillSearchAppService {
|
|||
}
|
||||
|
||||
private boolean hasPlatformWideReadAccess(Set<String> 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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <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(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<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");
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -105,7 +105,6 @@ public class PostgresFullTextQueryService implements SearchQueryService {
|
|||
Set<Long> 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) {
|
||||
|
|
|
|||
|
|
@ -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<String> 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
|
||||
|
|
|
|||
2
web/public/github-logo.svg
Normal file
2
web/public/github-logo.svg
Normal 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 |
2
web/public/gitlab-logo.svg
Normal file
2
web/public/gitlab-logo.svg
Normal 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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@
|
|||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "还没有账号?",
|
||||
"register": "立即注册",
|
||||
"oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。",
|
||||
"oauthHint": "使用 OAuth 登录时,认证完成后会自动返回当前站点。",
|
||||
"passwordCompatHint": "当前部署已启用账号密码兼容接入层。表单将路由到 {{name}},而不是固定使用本地账号接口。",
|
||||
"enterpriseSsoTitle": "企业单点登录",
|
||||
"enterpriseSsoHint": "当前部署已启用兼容接入层。若浏览器中已存在 {{name}} 会话,可直接尝试建立 SkillHub 登录态。",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue