From a0ebf1dd07f328d5162575b82550a7ff9ed22d57 Mon Sep 17 00:00:00 2001 From: simple321vip Date: Mon, 6 Jul 2026 22:31:15 +0800 Subject: [PATCH] feat: first commit --- .github/workflows/iam-ci.yml | 59 ++++ .gitignore | 54 ++++ AGENT.md | 11 + pom.xml | 137 ++++++++++ .../cn/violin/iam/ViolinIamApplication.java | 30 +++ .../iam/annotation/RequirePermission.java | 17 ++ .../iam/config/AuthentikProperties.java | 27 ++ .../iam/config/ServiceAuthProperties.java | 39 +++ .../iam/config/ViolinJwtProperties.java | 75 ++++++ .../violin/iam/config/WebSecurityConfig.java | 14 + .../iam/controller/InternalController.java | 52 ++++ .../iam/controller/OAuthController.java | 112 ++++++++ .../violin/iam/controller/RbacController.java | 73 +++++ .../violin/iam/controller/UserController.java | 45 ++++ .../java/cn/violin/iam/dto/AuthResponse.java | 21 ++ .../violin/iam/dto/CurrentUserResponse.java | 20 ++ .../violin/iam/dto/OidcCallbackRequest.java | 20 ++ .../iam/dto/SubjectAccessReviewRequest.java | 25 ++ .../iam/dto/SubjectAccessReviewResponse.java | 19 ++ .../violin/iam/dto/UpdateProfileRequest.java | 18 ++ .../violin/iam/entity/PolicyRuleEntity.java | 31 +++ .../violin/iam/entity/RoleBindingEntity.java | 32 +++ .../java/cn/violin/iam/entity/RoleEntity.java | 25 ++ .../violin/iam/entity/RolePolicyEntity.java | 19 ++ .../cn/violin/iam/entity/SubjectKind.java | 7 + .../violin/iam/mapper/PolicyRuleMapper.java | 9 + .../violin/iam/mapper/RoleBindingMapper.java | 9 + .../java/cn/violin/iam/mapper/RoleMapper.java | 9 + .../violin/iam/mapper/RolePolicyMapper.java | 9 + .../java/cn/violin/iam/mapper/UserMapper.java | 15 ++ .../iam/security/CurrentUserProvider.java | 20 ++ .../violin/iam/security/PermissionAspect.java | 67 +++++ .../violin/iam/security/ServiceAllowlist.java | 75 ++++++ .../iam/security/ServiceAuthValidator.java | 120 +++++++++ .../violin/iam/service/PolicyRuleParser.java | 94 +++++++ .../service/SubjectAccessReviewService.java | 254 ++++++++++++++++++ .../iam/service/UserProfileService.java | 15 ++ .../cn/violin/iam/service/UserService.java | 12 + .../service/impl/UserProfileServiceImpl.java | 49 ++++ .../iam/service/impl/UserServiceImpl.java | 43 +++ .../java/cn/violin/iam/sso/AuthentikConf.java | 48 ++++ .../cn/violin/iam/sso/JwksController.java | 45 ++++ .../java/cn/violin/iam/sso/JwtIssuer.java | 38 +++ .../java/cn/violin/iam/sso/OAuthService.java | 10 + .../cn/violin/iam/sso/OidcStateStore.java | 58 ++++ .../cn/violin/iam/sso/RsaKeyProvider.java | 150 +++++++++++ .../violin/iam/sso/impl/OAuthServiceImpl.java | 162 +++++++++++ src/main/resources/application-dev.yml | 30 +++ src/main/resources/application-prod.yml | 22 ++ src/main/resources/application.yml | 74 +++++ .../iam/security/ServiceAllowlistTest.java | 77 ++++++ .../security/ServiceAuthValidatorTest.java | 69 +++++ ...ubjectAccessReviewServiceCacheKeyTest.java | 86 ++++++ ...ctAccessReviewServiceVerifiedGateTest.java | 40 +++ .../iam/sso/OidcStateStoreContractTest.java | 46 ++++ .../cn/violin/iam/sso/OidcStateStoreTest.java | 47 ++++ 56 files changed, 2784 insertions(+) create mode 100644 .github/workflows/iam-ci.yml create mode 100644 .gitignore create mode 100644 AGENT.md create mode 100644 pom.xml create mode 100644 src/main/java/cn/violin/iam/ViolinIamApplication.java create mode 100644 src/main/java/cn/violin/iam/annotation/RequirePermission.java create mode 100644 src/main/java/cn/violin/iam/config/AuthentikProperties.java create mode 100644 src/main/java/cn/violin/iam/config/ServiceAuthProperties.java create mode 100644 src/main/java/cn/violin/iam/config/ViolinJwtProperties.java create mode 100644 src/main/java/cn/violin/iam/config/WebSecurityConfig.java create mode 100644 src/main/java/cn/violin/iam/controller/InternalController.java create mode 100644 src/main/java/cn/violin/iam/controller/OAuthController.java create mode 100644 src/main/java/cn/violin/iam/controller/RbacController.java create mode 100644 src/main/java/cn/violin/iam/controller/UserController.java create mode 100644 src/main/java/cn/violin/iam/dto/AuthResponse.java create mode 100644 src/main/java/cn/violin/iam/dto/CurrentUserResponse.java create mode 100644 src/main/java/cn/violin/iam/dto/OidcCallbackRequest.java create mode 100644 src/main/java/cn/violin/iam/dto/SubjectAccessReviewRequest.java create mode 100644 src/main/java/cn/violin/iam/dto/SubjectAccessReviewResponse.java create mode 100644 src/main/java/cn/violin/iam/dto/UpdateProfileRequest.java create mode 100644 src/main/java/cn/violin/iam/entity/PolicyRuleEntity.java create mode 100644 src/main/java/cn/violin/iam/entity/RoleBindingEntity.java create mode 100644 src/main/java/cn/violin/iam/entity/RoleEntity.java create mode 100644 src/main/java/cn/violin/iam/entity/RolePolicyEntity.java create mode 100644 src/main/java/cn/violin/iam/entity/SubjectKind.java create mode 100644 src/main/java/cn/violin/iam/mapper/PolicyRuleMapper.java create mode 100644 src/main/java/cn/violin/iam/mapper/RoleBindingMapper.java create mode 100644 src/main/java/cn/violin/iam/mapper/RoleMapper.java create mode 100644 src/main/java/cn/violin/iam/mapper/RolePolicyMapper.java create mode 100644 src/main/java/cn/violin/iam/mapper/UserMapper.java create mode 100644 src/main/java/cn/violin/iam/security/CurrentUserProvider.java create mode 100644 src/main/java/cn/violin/iam/security/PermissionAspect.java create mode 100644 src/main/java/cn/violin/iam/security/ServiceAllowlist.java create mode 100644 src/main/java/cn/violin/iam/security/ServiceAuthValidator.java create mode 100644 src/main/java/cn/violin/iam/service/PolicyRuleParser.java create mode 100644 src/main/java/cn/violin/iam/service/SubjectAccessReviewService.java create mode 100644 src/main/java/cn/violin/iam/service/UserProfileService.java create mode 100644 src/main/java/cn/violin/iam/service/UserService.java create mode 100644 src/main/java/cn/violin/iam/service/impl/UserProfileServiceImpl.java create mode 100644 src/main/java/cn/violin/iam/service/impl/UserServiceImpl.java create mode 100644 src/main/java/cn/violin/iam/sso/AuthentikConf.java create mode 100644 src/main/java/cn/violin/iam/sso/JwksController.java create mode 100644 src/main/java/cn/violin/iam/sso/JwtIssuer.java create mode 100644 src/main/java/cn/violin/iam/sso/OAuthService.java create mode 100644 src/main/java/cn/violin/iam/sso/OidcStateStore.java create mode 100644 src/main/java/cn/violin/iam/sso/RsaKeyProvider.java create mode 100644 src/main/java/cn/violin/iam/sso/impl/OAuthServiceImpl.java create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/cn/violin/iam/security/ServiceAllowlistTest.java create mode 100644 src/test/java/cn/violin/iam/security/ServiceAuthValidatorTest.java create mode 100644 src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceCacheKeyTest.java create mode 100644 src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceVerifiedGateTest.java create mode 100644 src/test/java/cn/violin/iam/sso/OidcStateStoreContractTest.java create mode 100644 src/test/java/cn/violin/iam/sso/OidcStateStoreTest.java diff --git a/.github/workflows/iam-ci.yml b/.github/workflows/iam-ci.yml new file mode 100644 index 0000000..4860130 --- /dev/null +++ b/.github/workflows/iam-ci.yml @@ -0,0 +1,59 @@ +name: iam-ci + +on: + push: + branches: [main] + pull_request: + paths: ['**'] + +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: violin + POSTGRES_PASSWORD: violin + POSTGRES_DB: violin + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U violin" + --health-interval=5s + --health-timeout=3s + --health-retries=5 + + env: + VIOLIN_IAM_SERVICE_TOKEN_SECRET: "0011223344556677889900aabbccddeeff" + VIOLIN_JWT_PRIVATE_KEY_PATH: "" + SPRING_PROFILES_ACTIVE: dev + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/violin + SPRING_DATASOURCE_USERNAME: violin + SPRING_DATASOURCE_PASSWORD: violin + VIOLIN_JWT_JWKS_URL: http://localhost:8080/violin-iam/.well-known/jwks.json + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: corretto + java-version: '17' + + - name: Cache Maven + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} + + - name: Install violin-parent + violin-common + violin-core + run: | + for m in violin-parent violin-common violin-core; do + echo "--- installing $m ---" + mvn -B -f "$m/pom.xml" -o clean install -DskipTests || mvn -B -f "$m/pom.xml" clean install -DskipTests + done + + - name: Build + test violin-iam + run: mvn -B -f violin-iam/pom.xml verify diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dadcaa3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### + +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### + +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### + +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### + +.vscode/ + +### opencode ### + +.opencode + +### project tmp file + +*.log + +### 凭据 / 环境变量(不要 commit 真实值) + +.env +application-local.yml + +### K8s Secret 模板(仅供参考,真实凭据通过 KubeSphere UI / CI/CD 注入) + +k8s/secret.yml diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..04b6bd3 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,11 @@ +# AGENT.md + + Violin IAM service: unified login, authentication, and authorization + microservice for the Violin suite. + +## Rules + + 1. Do not use PowerShell to read or write files. Prefer the built-in + file tools or Python instead. + 2. Use English in code. Avoid Chinese, Japanese, or any other non-ASCII + natural language inside source files. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..62b0d46 --- /dev/null +++ b/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + cn.violin + violin-parent + 2.1 + + + violin-iam + 2.1 + violin-iam + violin IAM service + + + + cn.violin + violin-core + 2.1 + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-aop + + + + com.baomidou + mybatis-plus-boot-starter + + + + org.postgresql + postgresql + + + + + org.flywaydb + flyway-core + + + + org.projectlombok + lombok + true + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + + org.apache.httpcomponents + httpclient + 4.5.13 + + + + + com.alibaba + fastjson + + + + + jakarta.validation + jakarta.validation-api + + + + org.springframework.boot + spring-boot-starter-test + 3.5.4 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/ViolinIamApplication.java b/src/main/java/cn/violin/iam/ViolinIamApplication.java new file mode 100644 index 0000000..53b183f --- /dev/null +++ b/src/main/java/cn/violin/iam/ViolinIamApplication.java @@ -0,0 +1,30 @@ +package cn.violin.iam; + +import cn.violin.iam.config.AuthentikProperties; +import cn.violin.iam.config.ServiceAuthProperties; +import cn.violin.iam.config.ViolinJwtProperties; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; + +@SpringBootApplication +@MapperScan("cn.violin") +@EnableConfigurationProperties({ + ViolinJwtProperties.class, + AuthentikProperties.class, + ServiceAuthProperties.class +}) +public class ViolinIamApplication extends SpringBootServletInitializer { + + public static void main(String[] args) { + SpringApplication.run(ViolinIamApplication.class, args); + } + + @Override + protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { + return builder.sources(this.getClass()); + } +} diff --git a/src/main/java/cn/violin/iam/annotation/RequirePermission.java b/src/main/java/cn/violin/iam/annotation/RequirePermission.java new file mode 100644 index 0000000..5601921 --- /dev/null +++ b/src/main/java/cn/violin/iam/annotation/RequirePermission.java @@ -0,0 +1,17 @@ +package cn.violin.iam.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequirePermission { + + String apiGroup() default "violin"; + + String resource(); + + String verb(); +} diff --git a/src/main/java/cn/violin/iam/config/AuthentikProperties.java b/src/main/java/cn/violin/iam/config/AuthentikProperties.java new file mode 100644 index 0000000..9379896 --- /dev/null +++ b/src/main/java/cn/violin/iam/config/AuthentikProperties.java @@ -0,0 +1,27 @@ +package cn.violin.iam.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration bundle for Authentik OIDC client. + */ +@ConfigurationProperties(prefix = "authentik") +public class AuthentikProperties { + + private String issuer = ""; + private String clientId = ""; + private String clientSecret = ""; + private String redirectUri = ""; + private String scope = "openid profile email"; + + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + public String getClientId() { return clientId; } + public void setClientId(String clientId) { this.clientId = clientId; } + public String getClientSecret() { return clientSecret; } + public void setClientSecret(String clientSecret) { this.clientSecret = clientSecret; } + public String getRedirectUri() { return redirectUri; } + public void setRedirectUri(String redirectUri) { this.redirectUri = redirectUri; } + public String getScope() { return scope; } + public void setScope(String scope) { this.scope = scope; } +} diff --git a/src/main/java/cn/violin/iam/config/ServiceAuthProperties.java b/src/main/java/cn/violin/iam/config/ServiceAuthProperties.java new file mode 100644 index 0000000..ac0ff73 --- /dev/null +++ b/src/main/java/cn/violin/iam/config/ServiceAuthProperties.java @@ -0,0 +1,39 @@ +package cn.violin.iam.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration bundle for service-to-service internal-call trust: + * shared HMAC secret (hex), allowlist of serviceId → tenants. + */ +@ConfigurationProperties(prefix = "violin.iam") +public class ServiceAuthProperties { + + /** Hex-encoded shared HMAC key. Must be set for non-local profiles. */ + private String serviceTokenSecret = ""; + + /** + * CSV format: {@code svc1=t1:t2,svc2=*} where {@code *} means cluster-wide. + * Required for incoming service-to-service calls. + */ + private String serviceAllowlist = ""; + + /** Outbound service id used when this service calls another service. */ + private String serviceId = "violin-caller"; + + /** Active Spring profile (drives blank-secret fallback). */ + private String profile = "local"; + + public boolean isLocal() { + return "local".equalsIgnoreCase(profile); + } + + public String getProfile() { return profile; } + public void setProfile(String profile) { this.profile = profile; } + public String getServiceTokenSecret() { return serviceTokenSecret; } + public void setServiceTokenSecret(String s) { this.serviceTokenSecret = s; } + public String getServiceAllowlist() { return serviceAllowlist; } + public void setServiceAllowlist(String s) { this.serviceAllowlist = s; } + public String getServiceId() { return serviceId; } + public void setServiceId(String s) { this.serviceId = s; } +} diff --git a/src/main/java/cn/violin/iam/config/ViolinJwtProperties.java b/src/main/java/cn/violin/iam/config/ViolinJwtProperties.java new file mode 100644 index 0000000..fc57af6 --- /dev/null +++ b/src/main/java/cn/violin/iam/config/ViolinJwtProperties.java @@ -0,0 +1,75 @@ +package cn.violin.iam.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration bundle for violin.jwt.* (issuer/audience/keys/JWKS/cache). + * + *

Bound via {@code @EnableConfigurationProperties(ViolinJwtProperties.class)} + * inside {@link VioliamJwtAutoConfiguration} so this class is framework-agnostic + * (just a record of values + a JSR-303 validation hook).

+ */ +@ConfigurationProperties(prefix = "violin.jwt") +public class ViolinJwtProperties { + + /** Expected {@code iss} claim on inbound tokens. */ + private String issuer = "violin-iam"; + + /** Expected {@code aud} claim. */ + private String audience = "violin-services"; + + /** Token lifetime in milliseconds. */ + private long expirationMs = 86_400_000L; + + /** Active signing key id (kept in JWK header for rotation). */ + private String keyId = "violin-iam-1"; + + /** PEM PKCS8 private key path (profile != local). */ + private String privateKeyPath = ""; + + /** PEM X509 public key path (profile != local). */ + private String publicKeyPath = ""; + + /** JWKS URL that verifiers should poll. */ + private String jwksUrl = ""; + + /** JWKS cache TTL in seconds. */ + private long refreshSeconds = 300L; + + /** Acceptable clock skew on verify. */ + private long clockSkewSeconds = 30L; + + /** + * Active Spring profile. When {@code local}, in-memory RSA keys are + * generated and PEM files are not required. + */ + private String profile = "local"; + + /** + * Convenience: true when {@link #profile} equals {@code local} (case-insensitive). + */ + public boolean isLocal() { + return "local".equalsIgnoreCase(profile); + } + + public String getProfile() { return profile; } + public void setProfile(String profile) { this.profile = profile; } + public String getIssuer() { return issuer; } + public void setIssuer(String issuer) { this.issuer = issuer; } + public String getAudience() { return audience; } + public void setAudience(String audience) { this.audience = audience; } + public long getExpirationMs() { return expirationMs; } + public void setExpirationMs(long expirationMs) { this.expirationMs = expirationMs; } + public String getKeyId() { return keyId; } + public void setKeyId(String keyId) { this.keyId = keyId; } + public String getPrivateKeyPath() { return privateKeyPath; } + public void setPrivateKeyPath(String p) { this.privateKeyPath = p; } + public String getPublicKeyPath() { return publicKeyPath; } + public void setPublicKeyPath(String p) { this.publicKeyPath = p; } + public String getJwksUrl() { return jwksUrl; } + public void setJwksUrl(String jwksUrl) { this.jwksUrl = jwksUrl; } + public long getRefreshSeconds() { return refreshSeconds; } + public void setRefreshSeconds(long s) { this.refreshSeconds = s; } + public long getClockSkewSeconds() { return clockSkewSeconds; } + public void setClockSkewSeconds(long s) { this.clockSkewSeconds = s; } +} diff --git a/src/main/java/cn/violin/iam/config/WebSecurityConfig.java b/src/main/java/cn/violin/iam/config/WebSecurityConfig.java new file mode 100644 index 0000000..f41327d --- /dev/null +++ b/src/main/java/cn/violin/iam/config/WebSecurityConfig.java @@ -0,0 +1,14 @@ +package cn.violin.iam.config; + +/** + * Placeholder. Authentication in violin-iam is interceptor-based + * ({@link cn.violin.core.interceptor.AuthenticationInterceptor}), auto-wired + * via {@link cn.violin.core.config.WebMvcConfig}. This class intentionally + * carries no overrides; do not add Spring Security setup here unless you + * also add spring-boot-starter-security to the pom and rewire the + * {@code AuthenticationInterceptor} as a Spring Security filter chain + * element. See {@code docs/iam/security.md} (TODO) for the rationale. + */ +public final class WebSecurityConfig { + private WebSecurityConfig() {} +} diff --git a/src/main/java/cn/violin/iam/controller/InternalController.java b/src/main/java/cn/violin/iam/controller/InternalController.java new file mode 100644 index 0000000..34f4de8 --- /dev/null +++ b/src/main/java/cn/violin/iam/controller/InternalController.java @@ -0,0 +1,52 @@ +package cn.violin.iam.controller; + +import cn.violin.core.iam.CheckRequest; +import cn.violin.core.iam.CheckResult; +import cn.violin.iam.dto.SubjectAccessReviewRequest; +import cn.violin.iam.dto.SubjectAccessReviewResponse; +import cn.violin.iam.security.ServiceAllowlist; +import cn.violin.iam.security.ServiceAuthValidator; +import cn.violin.iam.service.SubjectAccessReviewService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/internal") +@RequiredArgsConstructor +@Tag(name = "Internal") +public class InternalController { + + private final SubjectAccessReviewService sarService; + private final ServiceAuthValidator serviceAuth; + private final ServiceAllowlist allowlist; + + @PostMapping("/check-permission") + public CheckResult checkPermission(HttpServletRequest httpReq, + @RequestBody CheckRequest req) { + String serviceId = serviceAuth.verifyAndReturnService(httpReq); + // ServiceAllowlist mapping is the trusted provenance here: the calling + // service's identity was authenticated via HMAC and its allowed tenants + // are defined in deployment config. We pass customerVerified=true so SAR + // accepts the customer scope. + String resolvedCustomerId = allowlist.resolveTenant(serviceId, req.getCustomerId()); + + SubjectAccessReviewResponse resp = sarService.check(SubjectAccessReviewRequest.builder() + .userSub(req.getUserId()) + .customerId(resolvedCustomerId) + .customerVerified(true) + .apiGroup("violin") + .resource(req.getResource()) + .verb(req.getAction()) + .resourceName(req.getResourceName()) + .build()); + return CheckResult.builder() + .allowed(Boolean.TRUE.equals(resp.getAllowed())) + .reason(resp.getReason()) + .build(); + } +} diff --git a/src/main/java/cn/violin/iam/controller/OAuthController.java b/src/main/java/cn/violin/iam/controller/OAuthController.java new file mode 100644 index 0000000..f5c8ac0 --- /dev/null +++ b/src/main/java/cn/violin/iam/controller/OAuthController.java @@ -0,0 +1,112 @@ +package cn.violin.iam.controller; + +import cn.violin.common.annotation.PassToken; +import cn.violin.common.api.ApiResponse; +import cn.violin.common.context.RequestContext; +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.core.entity.UserEntity; +import cn.violin.core.security.JwtUtils; +import cn.violin.iam.dto.AuthResponse; +import cn.violin.iam.dto.CurrentUserResponse; +import cn.violin.iam.dto.OidcCallbackRequest; +import cn.violin.iam.service.UserService; +import cn.violin.iam.sso.AuthentikConf; +import cn.violin.iam.sso.OAuthService; +import cn.violin.iam.sso.OidcStateStore; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +@AllArgsConstructor +@Slf4j +@Tag(name = "OAuth") +public class OAuthController { + + private final OAuthService oAuthService; + private final UserService userService; + private final OidcStateStore stateStore; + private final AuthentikConf authentikConf; + private final JwtUtils jwtUtils; + + @GetMapping("/auth/oidc/authorize") + @PassToken + public ApiResponse> authorize() { + String state = stateStore.issue(); + String url = authentikConf.getAuthorizeUrl() + + "?response_type=code" + + "&client_id=" + authentikConf.getClientId() + + "&redirect_uri=" + authentikConf.getRedirectUri() + + "&scope=" + authentikConf.getScope() + + "&state=" + state; + Map body = new HashMap<>(); + body.put("authorizeUrl", url); + body.put("state", state); + return ApiResponse.ok(body); + } + + @PostMapping("/auth/oidc/callback") + @PassToken + public ApiResponse callback(@RequestBody OidcCallbackRequest request) throws IOException { + return ApiResponse.ok(doCallback(request.getCode(), request.getState())); + } + + private AuthResponse doCallback(String code, String state) throws IOException { + if (!stateStore.consume(state)) { + throw new UnauthorizedException("state invalid or expired"); + } + return oAuthService.oidcAuthorize(code, state); + } + + @PostMapping("/auth/oidc/logout") + public ApiResponse> logout(HttpServletRequest req) { + String auth = req.getHeader(HttpHeaders.AUTHORIZATION); + if (!StringUtils.hasLength(auth)) { + throw new UnauthorizedException("token invalid or missing"); + } + String[] parts = auth.split("\\s+", 2); + if (parts.length != 2 || !"Bearer".equalsIgnoreCase(parts[0])) { + throw new UnauthorizedException("authorization format invalid"); + } + String jti = jwtUtils.extractJti(parts[1]); + if (jti != null) { + jwtUtils.revoke(jti); + log.info("token jti {} revoked by userId={}", jti, RequestContext.getUserId()); + } + Map body = new HashMap<>(); + body.put("revokedJti", jti == null ? "" : jti); + return ApiResponse.ok(body); + } + + @GetMapping("/me") + public ApiResponse me() { + String userId = RequestContext.getUserId(); + if (userId == null || userId.isBlank()) { + throw new UnauthorizedException("token invalid or missing"); + } + UserEntity user = userService.findById(userId) + .orElseThrow(() -> new UnauthorizedException("user not found: " + userId)); + return ApiResponse.ok(toResponse(user)); + } + + static CurrentUserResponse toResponse(UserEntity user) { + return new CurrentUserResponse( + user.getUserId(), + user.getCustomerId(), + user.getUsername(), + user.getEmail(), + user.getAvatarUrl(), + user.getLastLoginTime()); + } +} diff --git a/src/main/java/cn/violin/iam/controller/RbacController.java b/src/main/java/cn/violin/iam/controller/RbacController.java new file mode 100644 index 0000000..c22468c --- /dev/null +++ b/src/main/java/cn/violin/iam/controller/RbacController.java @@ -0,0 +1,73 @@ +package cn.violin.iam.controller; + +import cn.violin.common.api.ApiResponse; +import cn.violin.common.api.ApiResponseBody; +import cn.violin.common.context.RequestContext; +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.core.entity.UserEntity; +import cn.violin.iam.annotation.RequirePermission; +import cn.violin.iam.dto.CurrentUserResponse; +import cn.violin.iam.dto.SubjectAccessReviewRequest; +import cn.violin.iam.dto.SubjectAccessReviewResponse; +import cn.violin.iam.service.SubjectAccessReviewService; +import cn.violin.iam.service.UserService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/rbac") +@RequiredArgsConstructor +@Tag(name = "RBAC") +public class RbacController { + + private final SubjectAccessReviewService sarService; + private final UserService userService; + + @GetMapping("/access-check") + @ApiResponseBody + public SubjectAccessReviewResponse check(@RequestParam String apiGroup, + @RequestParam String resource, + @RequestParam String verb, + @RequestParam(required = false) String resourceName) { + String verifiedCustomerId = RequestContext.getVerifiedCustomerId(); + String userSub = RequestContext.getUserId(); + if (userSub == null || userSub.isBlank()) { + throw new UnauthorizedException("token invalid or missing"); + } + if (verifiedCustomerId == null) { + throw new UnauthorizedException("customerId not verified; cannot enforce tenant scope"); + } + return sarService.check(SubjectAccessReviewRequest.builder() + .userSub(userSub) + .customerId(verifiedCustomerId) + .customerVerified(true) + .apiGroup(apiGroup) + .resource(resource) + .verb(verb) + .resourceName(resourceName) + .build()); + } + + @GetMapping("/users") + @RequirePermission(apiGroup = "violin", resource = "user", verb = "list") + @ApiResponseBody + public ApiResponse listUsers() { + String userId = RequestContext.getUserId(); + if (userId == null || userId.isBlank()) { + throw new UnauthorizedException("token invalid or missing"); + } + UserEntity user = userService.findById(userId) + .orElseThrow(() -> new UnauthorizedException("user not found: " + userId)); + return ApiResponse.ok(new CurrentUserResponse( + user.getUserId(), + user.getCustomerId(), + user.getUsername(), + user.getEmail(), + user.getAvatarUrl(), + user.getLastLoginTime())); + } +} diff --git a/src/main/java/cn/violin/iam/controller/UserController.java b/src/main/java/cn/violin/iam/controller/UserController.java new file mode 100644 index 0000000..d054ceb --- /dev/null +++ b/src/main/java/cn/violin/iam/controller/UserController.java @@ -0,0 +1,45 @@ +package cn.violin.iam.controller; + +import cn.violin.core.entity.UserEntity; +import cn.violin.iam.dto.CurrentUserResponse; +import cn.violin.iam.dto.UpdateProfileRequest; +import cn.violin.iam.service.UserProfileService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users") +@RequiredArgsConstructor +@Tag(name = "Users") +public class UserController { + + private final UserProfileService profileService; + + @GetMapping("/me") + public CurrentUserResponse me() { + UserEntity user = profileService.getCurrent(); + return toResponse(user); + } + + @PutMapping("/me") + public CurrentUserResponse updateMe(@Valid @RequestBody UpdateProfileRequest request) { + UserEntity user = profileService.updateCurrent(request); + return toResponse(user); + } + + private static CurrentUserResponse toResponse(UserEntity user) { + return new CurrentUserResponse( + user.getUserId(), + user.getCustomerId(), + user.getUsername(), + user.getEmail(), + user.getAvatarUrl(), + user.getLastLoginTime()); + } +} diff --git a/src/main/java/cn/violin/iam/dto/AuthResponse.java b/src/main/java/cn/violin/iam/dto/AuthResponse.java new file mode 100644 index 0000000..c961c29 --- /dev/null +++ b/src/main/java/cn/violin/iam/dto/AuthResponse.java @@ -0,0 +1,21 @@ +package cn.violin.iam.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponse { + + private String token; + + private String sub; + + private String username; + + private String email; + + private String avatarUrl; +} \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/dto/CurrentUserResponse.java b/src/main/java/cn/violin/iam/dto/CurrentUserResponse.java new file mode 100644 index 0000000..418307d --- /dev/null +++ b/src/main/java/cn/violin/iam/dto/CurrentUserResponse.java @@ -0,0 +1,20 @@ +package cn.violin.iam.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CurrentUserResponse { + + private String userId; + private String customerId; + private String username; + private String email; + private String avatarUrl; + private OffsetDateTime lastLoginTime; +} diff --git a/src/main/java/cn/violin/iam/dto/OidcCallbackRequest.java b/src/main/java/cn/violin/iam/dto/OidcCallbackRequest.java new file mode 100644 index 0000000..00ba49b --- /dev/null +++ b/src/main/java/cn/violin/iam/dto/OidcCallbackRequest.java @@ -0,0 +1,20 @@ +package cn.violin.iam.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OidcCallbackRequest { + + @NotBlank + private String code; + + private String redirect_uri; + + @NotBlank + private String state; +} diff --git a/src/main/java/cn/violin/iam/dto/SubjectAccessReviewRequest.java b/src/main/java/cn/violin/iam/dto/SubjectAccessReviewRequest.java new file mode 100644 index 0000000..3d848ff --- /dev/null +++ b/src/main/java/cn/violin/iam/dto/SubjectAccessReviewRequest.java @@ -0,0 +1,25 @@ +package cn.violin.iam.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubjectAccessReviewRequest { + private String userSub; + private String customerId; + /** + * True iff {@link #customerId} was resolved through a trusted path + * (e.g. database lookup, internal service allowlist mapping). When false, + * SAR rejects the request — fail-closed against untrusted JWT claims. + */ + private boolean customerVerified; + private String apiGroup; + private String resource; + private String verb; + private String resourceName; +} diff --git a/src/main/java/cn/violin/iam/dto/SubjectAccessReviewResponse.java b/src/main/java/cn/violin/iam/dto/SubjectAccessReviewResponse.java new file mode 100644 index 0000000..f5b7e47 --- /dev/null +++ b/src/main/java/cn/violin/iam/dto/SubjectAccessReviewResponse.java @@ -0,0 +1,19 @@ +package cn.violin.iam.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SubjectAccessReviewResponse { + private Boolean allowed; + private String reason; + private List matchedRoles; + private List allowedResourceNames; +} diff --git a/src/main/java/cn/violin/iam/dto/UpdateProfileRequest.java b/src/main/java/cn/violin/iam/dto/UpdateProfileRequest.java new file mode 100644 index 0000000..9b9bd78 --- /dev/null +++ b/src/main/java/cn/violin/iam/dto/UpdateProfileRequest.java @@ -0,0 +1,18 @@ +package cn.violin.iam.dto; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateProfileRequest { + + @Size(max = 64) + private String username; + + @Size(max = 512) + private String avatarUrl; +} \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/entity/PolicyRuleEntity.java b/src/main/java/cn/violin/iam/entity/PolicyRuleEntity.java new file mode 100644 index 0000000..ece5c27 --- /dev/null +++ b/src/main/java/cn/violin/iam/entity/PolicyRuleEntity.java @@ -0,0 +1,31 @@ +package cn.violin.iam.entity; + +import cn.violin.common.entity.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_policy_rule") +public class PolicyRuleEntity extends BaseEntity { + + @TableField("name") + private String name; + + @TableField("api_groups") + private String apiGroups; + + @TableField("resources") + private String resources; + + @TableField("verbs") + private String verbs; + + @TableField("resource_names") + private String resourceNames; + + @TableField("description") + private String description; +} diff --git a/src/main/java/cn/violin/iam/entity/RoleBindingEntity.java b/src/main/java/cn/violin/iam/entity/RoleBindingEntity.java new file mode 100644 index 0000000..150ecc7 --- /dev/null +++ b/src/main/java/cn/violin/iam/entity/RoleBindingEntity.java @@ -0,0 +1,32 @@ +package cn.violin.iam.entity; + +import cn.violin.common.entity.BaseEntity; +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.ZonedDateTime; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_role_binding") +public class RoleBindingEntity extends BaseEntity { + + @EnumValue + @TableField("subject_kind") + private SubjectKind subjectKind; + + @TableField("subject_id") + private String subjectId; + + @TableField("role_id") + private String roleId; + + @TableField("binding_name") + private String bindingName; + + @TableField("expire_time") + private ZonedDateTime expireTime; +} diff --git a/src/main/java/cn/violin/iam/entity/RoleEntity.java b/src/main/java/cn/violin/iam/entity/RoleEntity.java new file mode 100644 index 0000000..d2f1ecc --- /dev/null +++ b/src/main/java/cn/violin/iam/entity/RoleEntity.java @@ -0,0 +1,25 @@ +package cn.violin.iam.entity; + +import cn.violin.common.entity.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_role") +public class RoleEntity extends BaseEntity { + + @TableField("name") + private String name; + + @TableField("customer_id") + private String customerId; + + @TableField("is_cluster_role") + private Boolean clusterRole; + + @TableField("description") + private String description; +} diff --git a/src/main/java/cn/violin/iam/entity/RolePolicyEntity.java b/src/main/java/cn/violin/iam/entity/RolePolicyEntity.java new file mode 100644 index 0000000..f40a847 --- /dev/null +++ b/src/main/java/cn/violin/iam/entity/RolePolicyEntity.java @@ -0,0 +1,19 @@ +package cn.violin.iam.entity; + +import cn.violin.common.entity.BaseEntity; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("t_role_policy") +public class RolePolicyEntity extends BaseEntity { + + @TableField("role_id") + private String roleId; + + @TableField("policy_id") + private String policyId; +} diff --git a/src/main/java/cn/violin/iam/entity/SubjectKind.java b/src/main/java/cn/violin/iam/entity/SubjectKind.java new file mode 100644 index 0000000..78dfd12 --- /dev/null +++ b/src/main/java/cn/violin/iam/entity/SubjectKind.java @@ -0,0 +1,7 @@ +package cn.violin.iam.entity; + +public enum SubjectKind { + USER, + GROUP, + SERVICE_ACCOUNT +} diff --git a/src/main/java/cn/violin/iam/mapper/PolicyRuleMapper.java b/src/main/java/cn/violin/iam/mapper/PolicyRuleMapper.java new file mode 100644 index 0000000..5fa1f19 --- /dev/null +++ b/src/main/java/cn/violin/iam/mapper/PolicyRuleMapper.java @@ -0,0 +1,9 @@ +package cn.violin.iam.mapper; + +import cn.violin.iam.entity.PolicyRuleEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PolicyRuleMapper extends BaseMapper { +} diff --git a/src/main/java/cn/violin/iam/mapper/RoleBindingMapper.java b/src/main/java/cn/violin/iam/mapper/RoleBindingMapper.java new file mode 100644 index 0000000..136680d --- /dev/null +++ b/src/main/java/cn/violin/iam/mapper/RoleBindingMapper.java @@ -0,0 +1,9 @@ +package cn.violin.iam.mapper; + +import cn.violin.iam.entity.RoleBindingEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RoleBindingMapper extends BaseMapper { +} diff --git a/src/main/java/cn/violin/iam/mapper/RoleMapper.java b/src/main/java/cn/violin/iam/mapper/RoleMapper.java new file mode 100644 index 0000000..51b59d7 --- /dev/null +++ b/src/main/java/cn/violin/iam/mapper/RoleMapper.java @@ -0,0 +1,9 @@ +package cn.violin.iam.mapper; + +import cn.violin.iam.entity.RoleEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RoleMapper extends BaseMapper { +} diff --git a/src/main/java/cn/violin/iam/mapper/RolePolicyMapper.java b/src/main/java/cn/violin/iam/mapper/RolePolicyMapper.java new file mode 100644 index 0000000..661e48f --- /dev/null +++ b/src/main/java/cn/violin/iam/mapper/RolePolicyMapper.java @@ -0,0 +1,9 @@ +package cn.violin.iam.mapper; + +import cn.violin.iam.entity.RolePolicyEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface RolePolicyMapper extends BaseMapper { +} diff --git a/src/main/java/cn/violin/iam/mapper/UserMapper.java b/src/main/java/cn/violin/iam/mapper/UserMapper.java new file mode 100644 index 0000000..4aeb3a0 --- /dev/null +++ b/src/main/java/cn/violin/iam/mapper/UserMapper.java @@ -0,0 +1,15 @@ +package cn.violin.iam.mapper; + +import cn.violin.core.entity.UserEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.Optional; + +@Mapper +public interface UserMapper extends BaseMapper { + + @Select("SELECT * FROM t_user WHERE user_id = #{userId} AND is_deleted = FALSE LIMIT 1") + Optional selectByUserId(String userId); +} \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/security/CurrentUserProvider.java b/src/main/java/cn/violin/iam/security/CurrentUserProvider.java new file mode 100644 index 0000000..414d01b --- /dev/null +++ b/src/main/java/cn/violin/iam/security/CurrentUserProvider.java @@ -0,0 +1,20 @@ +package cn.violin.iam.security; + +import cn.violin.common.context.RequestContext; +import org.springframework.stereotype.Component; + +@Component +public class CurrentUserProvider { + + public String getUserSub() { + return RequestContext.getUserId(); + } + + public String getCustomerId() { + return RequestContext.getCustomerId(); + } + + public boolean isAuthenticated() { + return getUserSub() != null; + } +} diff --git a/src/main/java/cn/violin/iam/security/PermissionAspect.java b/src/main/java/cn/violin/iam/security/PermissionAspect.java new file mode 100644 index 0000000..43b1c46 --- /dev/null +++ b/src/main/java/cn/violin/iam/security/PermissionAspect.java @@ -0,0 +1,67 @@ +package cn.violin.iam.security; + +import cn.violin.common.exception.PermissionDeniedException; +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.core.aspect.AspectOrders; +import cn.violin.iam.annotation.RequirePermission; +import cn.violin.iam.dto.SubjectAccessReviewRequest; +import cn.violin.iam.dto.SubjectAccessReviewResponse; +import cn.violin.iam.service.SubjectAccessReviewService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +@Order(AspectOrders.PERMISSION) +public class PermissionAspect { + + private final SubjectAccessReviewService sarService; + private final CurrentUserProvider currentUser; + + @Around("@annotation(RequirePermission)") + public Object check(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RequirePermission annotation = method.getAnnotation(RequirePermission.class); + + String sub = currentUser.getUserSub(); + if (sub == null) { + throw new UnauthorizedException(); + } + + String verifiedCustomerId = cn.violin.common.context.RequestContext.getVerifiedCustomerId(); + if (verifiedCustomerId == null) { + log.warn("permission denied: customerId not verified for user={}; " + + "RBAC refuses unverified tenant scope", sub); + throw new UnauthorizedException("customerId not verified; cannot enforce tenant scope"); + } + + SubjectAccessReviewRequest req = SubjectAccessReviewRequest.builder() + .userSub(sub) + .customerId(verifiedCustomerId) + .customerVerified(true) + .apiGroup(annotation.apiGroup()) + .resource(annotation.resource()) + .verb(annotation.verb()) + .build(); + + SubjectAccessReviewResponse result = sarService.check(req); + if (!Boolean.TRUE.equals(result.getAllowed())) { + log.warn("permission denied: user={}, ({} {} {}) - {}", + sub, req.getApiGroup(), req.getResource(), req.getVerb(), result.getReason()); + throw new PermissionDeniedException("common.forbidden", new Object[]{result.getReason()}); + } + + return joinPoint.proceed(); + } +} diff --git a/src/main/java/cn/violin/iam/security/ServiceAllowlist.java b/src/main/java/cn/violin/iam/security/ServiceAllowlist.java new file mode 100644 index 0000000..e6853ef --- /dev/null +++ b/src/main/java/cn/violin/iam/security/ServiceAllowlist.java @@ -0,0 +1,75 @@ +package cn.violin.iam.security; + +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.iam.config.ServiceAuthProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Service-identity allowlist for internal callers. + * + *

Each service identity carries an explicit set of tenants it may operate on. + * A check request that names a {@code customerId} the caller is not authorised + * for is rejected with 401 (fail-closed). Use {@code *} to mark cluster-wide.

+ * + *

This class is itself a Spring bean (annotated {@link Configuration}); do not + * expose redundant {@code @Bean} factory methods that return {@code this}.

+ */ +@Configuration +@Slf4j +public class ServiceAllowlist { + + private static final String CLUSTER_WIDE = "*"; + + private final Map> allowlist; + + public ServiceAllowlist(ServiceAuthProperties props) { + Map> map = new HashMap<>(); + String csv = props.getServiceAllowlist(); + if (csv != null && !csv.isBlank()) { + for (String entry : csv.split(",")) { + String trimmed = entry.trim(); + if (trimmed.isEmpty()) continue; + int eq = trimmed.indexOf('='); + if (eq < 0 || trimmed.indexOf('=', eq + 1) >= 0) { + throw new IllegalArgumentException( + "violin.iam.service-allowlist entry malformed: " + trimmed); + } + String svc = trimmed.substring(0, eq).trim(); + String[] tenants = trimmed.substring(eq + 1).trim().split(":"); + Set set = new HashSet<>(Arrays.asList(tenants)); + map.put(svc, Set.copyOf(set)); + } + } + this.allowlist = Map.copyOf(map); + log.info("service allowlist loaded: {}", allowlist.keySet()); + } + + public String resolveTenant(String serviceId, String requestedCustomerId) { + Set tenants = allowlist.get(serviceId); + if (tenants == null) { + throw new UnauthorizedException("service id unknown: " + serviceId); + } + if (tenants.contains(CLUSTER_WIDE)) { + if (requestedCustomerId == null || requestedCustomerId.isBlank()) { + throw new UnauthorizedException( + "service " + serviceId + " is cluster-wide; customerId required"); + } + return requestedCustomerId; + } + if (tenants.size() == 1 && (requestedCustomerId == null || requestedCustomerId.isBlank())) { + return tenants.iterator().next(); + } + if (!tenants.contains(requestedCustomerId)) { + throw new UnauthorizedException( + "service " + serviceId + " not allowed for tenant " + requestedCustomerId); + } + return requestedCustomerId; + } +} diff --git a/src/main/java/cn/violin/iam/security/ServiceAuthValidator.java b/src/main/java/cn/violin/iam/security/ServiceAuthValidator.java new file mode 100644 index 0000000..d67d2a6 --- /dev/null +++ b/src/main/java/cn/violin/iam/security/ServiceAuthValidator.java @@ -0,0 +1,120 @@ +package cn.violin.iam.security; + +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.iam.config.ServiceAuthProperties; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HexFormat; + +/** + * Inbound service-to-service token validator. + * + *

Verifies HMAC-SHA256 triple-header tokens: + *

+ *   X-Service-Id     <calling service identity>
+ *   X-Service-Timestamp <millis since epoch>
+ *   X-Service-Nonce   <one-shot UUID>
+ *   X-Service-Signature HEX(HMAC_SHA256(secret, serviceId|ts|nonce))
+ * 
+ * + *

Drift window is fixed at 5 minutes; nonces are deduplicated via a 6-minute + * Caffeine cache (same epoch as the drift window minus the clock-skew allowance). + * The {@code profile} value is read from {@link ServiceAuthProperties}; for + * {@code local} profile a hard-coded dev secret is allowed, non-local profiles + * fail-fast at bean construction if no secret is configured.

+ */ +@Component +@Slf4j +@EnableConfigurationProperties(ServiceAuthProperties.class) +public class ServiceAuthValidator { + + private static final String DEFAULT_DEV_SECRET = + "76696f6c696e2d6465762d736572766963652d746f6b656e2d6e6f742d666f722d70726f64"; + + private final byte[] secret; + private final long driftMillis; + + private final Cache seenNonces = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(6)) + .maximumSize(50_000) + .build(); + + public ServiceAuthValidator(ServiceAuthProperties props) { + String secretHex = props.getServiceTokenSecret(); + if (secretHex == null || secretHex.isBlank()) { + if (props.isLocal()) { + log.warn("violin.iam.service-token-secret not configured; using dev default (local profile only)"); + secretHex = DEFAULT_DEV_SECRET; + } else { + throw new IllegalStateException( + "violin.iam.service-token-secret must be configured for profile=" + props.getProfile()); + } + } + this.secret = HexFormat.of().parseHex(secretHex); + this.driftMillis = 5L * 60 * 1000; + } + + public String verifyAndReturnService(HttpServletRequest req) { + String serviceId = req.getHeader("X-Service-Id"); + String tsRaw = req.getHeader("X-Service-Timestamp"); + String nonce = req.getHeader("X-Service-Nonce"); + String sig = req.getHeader("X-Service-Signature"); + if (serviceId == null || tsRaw == null || nonce == null || sig == null) { + throw new UnauthorizedException("service token fields missing"); + } + if (serviceId.isBlank() || nonce.isBlank() || nonce.length() > 128) { + throw new UnauthorizedException("service token format invalid"); + } + long ts; + try { + ts = Long.parseLong(tsRaw); + } catch (NumberFormatException e) { + throw new UnauthorizedException("service token ts invalid"); + } + long now = System.currentTimeMillis(); + if (Math.abs(now - ts) > driftMillis) { + throw new UnauthorizedException("service token expired or skewed"); + } + String expect = sign(serviceId, ts, nonce); + if (!constantTimeEquals(expect, sig)) { + throw new UnauthorizedException("service token signature mismatch"); + } + String key = serviceId + ":" + nonce; + if (seenNonces.getIfPresent(key) != null) { + throw new UnauthorizedException("service token replay detected"); + } + seenNonces.put(key, Boolean.TRUE); + log.debug("service {} authenticated (ts={})", serviceId, ts); + return serviceId; + } + + public String sign(String serviceId, long timestamp, String nonce) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + byte[] raw = mac.doFinal( + (serviceId + ":" + timestamp + ":" + nonce).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(raw); + } catch (Exception e) { + throw new IllegalStateException("HMAC computation failed", e); + } + } + + private static boolean constantTimeEquals(String a, String b) { + if (a == null || b == null || a.length() != b.length()) return false; + int diff = 0; + for (int i = 0; i < a.length(); i++) { + diff |= a.charAt(i) ^ b.charAt(i); + } + return diff == 0; + } +} diff --git a/src/main/java/cn/violin/iam/service/PolicyRuleParser.java b/src/main/java/cn/violin/iam/service/PolicyRuleParser.java new file mode 100644 index 0000000..2cb3adc --- /dev/null +++ b/src/main/java/cn/violin/iam/service/PolicyRuleParser.java @@ -0,0 +1,94 @@ +package cn.violin.iam.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +/** + * Cached parser for {@link cn.violin.iam.entity.PolicyRuleEntity} JSON columns + * (api_groups / resources / verbs / resource_names). + * + *

Two flavors are exposed: + *

    + *
  • {@link #parseSet(String)} returns {@link Optional#empty()} for blank input + * and throws {@code IllegalStateException} for malformed JSON. Use this on + * the SAR hot path so a malformed config is loud rather than silent.
  • + *
  • {@link #parseArray(String)} returns a {@code String[]} (empty for blank input, + * empty array for malformed JSON at WARN level). For non-critical read paths.
  • + *
+ */ +@Slf4j +@Component +public class PolicyRuleParser { + + private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + + private final ObjectMapper objectMapper; + private final Cache cache; + + public PolicyRuleParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(5)) + .maximumSize(10_000) + .build(); + } + + /** + * Parse a JSON array column into a {@link Set}. + *
    + *
  • blank input → {@code Optional.empty()}
  • + *
  • valid JSON → {@code Optional.of(set)}
  • + *
  • malformed JSON → throws {@link IllegalStateException}
  • + *
+ */ + public Optional> parseSet(String json) { + String[] arr = parseArrayStrict(json); + if (arr == null || arr.length == 0) return Optional.empty(); + return Optional.of(new HashSet<>(Arrays.asList(arr))); + } + + /** + * Parse a JSON array column into a {@code String[]}. + *
    + *
  • blank input → empty array
  • + *
  • valid JSON → parsed
  • + *
  • malformed JSON → empty array (WARN logged)
  • + *
+ */ + public String[] parseArray(String json) { + if (json == null || json.isBlank()) return new String[0]; + return cache.get(json, this::parseLenient); + } + + private String[] parseArrayStrict(String json) { + if (json == null || json.isBlank()) return null; + return cache.get(json, key -> { + try { + return objectMapper.readValue(key, STRING_LIST_TYPE).toArray(new String[0]); + } catch (Exception e) { + log.error("policy rule field JSON is malformed: {}", key, e); + throw new IllegalStateException("policy rule JSON malformed: " + key, e); + } + }); + } + + private String[] parseLenient(String json) { + try { + return objectMapper.readValue(json, STRING_LIST_TYPE).toArray(new String[0]); + } catch (Exception e) { + log.warn("policy rule field JSON is malformed; returning empty array: {}", json, e); + return new String[0]; + } + } +} diff --git a/src/main/java/cn/violin/iam/service/SubjectAccessReviewService.java b/src/main/java/cn/violin/iam/service/SubjectAccessReviewService.java new file mode 100644 index 0000000..ba6ad27 --- /dev/null +++ b/src/main/java/cn/violin/iam/service/SubjectAccessReviewService.java @@ -0,0 +1,254 @@ +package cn.violin.iam.service; + +import cn.violin.iam.dto.SubjectAccessReviewRequest; +import cn.violin.iam.dto.SubjectAccessReviewResponse; +import cn.violin.iam.entity.PolicyRuleEntity; +import cn.violin.iam.entity.RoleBindingEntity; +import cn.violin.iam.entity.RoleEntity; +import cn.violin.iam.entity.RolePolicyEntity; +import cn.violin.iam.entity.SubjectKind; +import cn.violin.iam.mapper.PolicyRuleMapper; +import cn.violin.iam.mapper.RoleBindingMapper; +import cn.violin.iam.mapper.RoleMapper; +import cn.violin.iam.mapper.RolePolicyMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class SubjectAccessReviewService { + + private final RoleBindingMapper roleBindingMapper; + private final RoleMapper roleMapper; + private final RolePolicyMapper rolePolicyMapper; + private final PolicyRuleMapper policyRuleMapper; + private final PolicyRuleParser parser; + + private final Cache allowCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(30)) + .maximumSize(10_000) + .build(); + + private final Cache denyCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(5)) + .maximumSize(50_000) + .build(); + + public SubjectAccessReviewService(RoleBindingMapper roleBindingMapper, + RoleMapper roleMapper, + RolePolicyMapper rolePolicyMapper, + PolicyRuleMapper policyRuleMapper, + PolicyRuleParser parser) { + this.roleBindingMapper = roleBindingMapper; + this.roleMapper = roleMapper; + this.rolePolicyMapper = rolePolicyMapper; + this.policyRuleMapper = policyRuleMapper; + this.parser = parser; + } + + public SubjectAccessReviewResponse check(SubjectAccessReviewRequest request) { + if (request.getUserSub() == null || request.getUserSub().isEmpty()) { + return denied("missing user sub"); + } + // Fail-closed: customerId without verified provenance (e.g. raw JWT + // claim) is rejected so SAR cannot be tricked into skipping tenant + // filtering. + if (!request.isCustomerVerified()) { + return denied("customerId is not verified; tenant scope cannot be enforced"); + } + + String cacheKey = buildCacheKey(request); + SubjectAccessReviewResponse allowCached = allowCache.getIfPresent(cacheKey); + if (allowCached != null) return allowCached; + SubjectAccessReviewResponse denyCached = denyCache.getIfPresent(cacheKey); + if (denyCached != null) return denyCached; + + SubjectAccessReviewResponse result = doCheck(request); + if (Boolean.TRUE.equals(result.getAllowed())) { + allowCache.put(cacheKey, result); + } else { + denyCache.put(cacheKey, result); + } + return result; + } + + private SubjectAccessReviewResponse doCheck(SubjectAccessReviewRequest request) { + List bindings = roleBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(RoleBindingEntity::getSubjectKind, SubjectKind.USER) + .eq(RoleBindingEntity::getSubjectId, request.getUserSub()) + .and(w -> w.isNull(RoleBindingEntity::getExpireTime) + .or().gt(RoleBindingEntity::getExpireTime, ZonedDateTime.now())) + ); + if (bindings.isEmpty()) { + return denied("no active role binding for user"); + } + + List roleIds = bindings.stream().map(RoleBindingEntity::getRoleId).collect(Collectors.toList()); + + if (request.getCustomerId() != null && !request.getCustomerId().isBlank()) { + List roles = roleMapper.selectBatchIds(roleIds); + Set scoped = roles.stream() + .filter(r -> Boolean.TRUE.equals(r.getClusterRole()) + || request.getCustomerId().equals(r.getCustomerId())) + .map(RoleEntity::getId) + .collect(Collectors.toSet()); + if (scoped.isEmpty()) { + return denied("no role binding for requested customer"); + } + roleIds = new ArrayList<>(scoped); + } + List rolePolicies = rolePolicyMapper.selectList( + new LambdaQueryWrapper().in(RolePolicyEntity::getRoleId, roleIds)); + if (rolePolicies.isEmpty()) { + return denied("user has roles but no policies attached"); + } + + List policyIds = rolePolicies.stream().map(RolePolicyEntity::getPolicyId).collect(Collectors.toList()); + Map policyMap = policyRuleMapper.selectBatchIds(policyIds).stream() + .collect(Collectors.toMap(PolicyRuleEntity::getId, Function.identity())); + Map roleMap = roleMapper.selectBatchIds(roleIds).stream() + .collect(Collectors.toMap(RoleEntity::getId, Function.identity())); + + Map> policyToRoles = rolePolicies.stream() + .collect(Collectors.groupingBy( + RolePolicyEntity::getPolicyId, + Collectors.mapping(RolePolicyEntity::getRoleId, Collectors.toList()))); + + Set matchedRoleNames = new HashSet<>(); + Set allowedResourceNames = null; + boolean anyAllowed = false; + + for (PolicyRuleEntity policy : policyMap.values()) { + if (!matches(policy, request)) continue; + + anyAllowed = true; + for (String rid : policyToRoles.getOrDefault(policy.getId(), Collections.emptyList())) { + RoleEntity roleEntity = roleMap.get(rid); + if (roleEntity != null) matchedRoleNames.add(roleEntity.getName()); + } + Set names = parser.parseSet(policy.getResourceNames()).orElse(null); + if (names == null) { + allowedResourceNames = null; + break; + } + if (allowedResourceNames == null) { + allowedResourceNames = new HashSet<>(names); + } else { + allowedResourceNames.addAll(names); + } + } + + if (!anyAllowed) { + return denied(String.format("no policy matches (apiGroup=%s, resource=%s, verb=%s)", + request.getApiGroup(), request.getResource(), request.getVerb())); + } + + return SubjectAccessReviewResponse.builder() + .allowed(true) + .reason("allowed") + .matchedRoles(new ArrayList<>(matchedRoleNames)) + .allowedResourceNames(allowedResourceNames == null ? null : new ArrayList<>(allowedResourceNames)) + .build(); + } + + public List listPolicies(String userSub, String customerId) { + if (userSub == null) return Collections.emptyList(); + + List bindings = roleBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(RoleBindingEntity::getSubjectKind, SubjectKind.USER) + .eq(RoleBindingEntity::getSubjectId, userSub) + .and(w -> w.isNull(RoleBindingEntity::getExpireTime) + .or().gt(RoleBindingEntity::getExpireTime, ZonedDateTime.now()))); + if (bindings.isEmpty()) return Collections.emptyList(); + + List roleIds = bindings.stream().map(RoleBindingEntity::getRoleId).collect(Collectors.toList()); + if (customerId != null && !customerId.isBlank()) { + List roles = roleMapper.selectBatchIds(roleIds); + Set scoped = roles.stream() + .filter(r -> Boolean.TRUE.equals(r.getClusterRole()) + || customerId.equals(r.getCustomerId())) + .map(RoleEntity::getId) + .collect(Collectors.toSet()); + if (scoped.isEmpty()) return Collections.emptyList(); + roleIds = new ArrayList<>(scoped); + } + + List rolePolicies = rolePolicyMapper.selectList( + new LambdaQueryWrapper().in(RolePolicyEntity::getRoleId, roleIds)); + if (rolePolicies.isEmpty()) return Collections.emptyList(); + + List policyIds = rolePolicies.stream().map(RolePolicyEntity::getPolicyId).collect(Collectors.toList()); + return policyRuleMapper.selectBatchIds(policyIds); + } + + public List listPolicies(String userSub) { + return listPolicies(userSub, null); + } + + private boolean matches(PolicyRuleEntity policy, SubjectAccessReviewRequest req) { + return matchArray(parser.parseArray(policy.getApiGroups()), req.getApiGroup()) + && matchArray(parser.parseArray(policy.getResources()), req.getResource()) + && matchArray(parser.parseArray(policy.getVerbs()), req.getVerb()); + } + + private boolean matchArray(String[] patterns, String value) { + if (patterns.length == 0 || value == null) return false; + for (String pattern : patterns) { + if ("*".equals(pattern) || pattern.equals(value)) return true; + if (pattern.endsWith("/*") && value.startsWith(pattern.substring(0, pattern.length() - 1))) { + return true; + } + } + return false; + } + + private String buildCacheKey(SubjectAccessReviewRequest req) { + // SHA-256 over the canonical key tuple. Field delimiters are part of the + // hash so collisions across fields of different lengths are avoided. + StringBuilder sb = new StringBuilder(192); + sb.append("u=").append(req.getUserSub() == null ? "" : req.getUserSub()); + sb.append('|').append("c=").append(req.getCustomerId() == null ? "" : req.getCustomerId()); + sb.append('|').append("vflag=").append(req.isCustomerVerified()); + sb.append('|').append("g=").append(req.getApiGroup() == null ? "" : req.getApiGroup()); + sb.append('|').append("r=").append(req.getResource() == null ? "" : req.getResource()); + sb.append('|').append("v=").append(req.getVerb() == null ? "" : req.getVerb()); + sb.append('|').append("n=").append(req.getResourceName() == null ? "" : req.getResourceName()); + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8)); + return "sar:" + HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is always available in the JDK; fall back to the raw canonical + // form rather than crashing the request path. + log.warn("SHA-256 unavailable, falling back to plain SAR cache key", e); + return sb.toString(); + } + } + + private SubjectAccessReviewResponse denied(String reason) { + return SubjectAccessReviewResponse.builder() + .allowed(false) + .reason(reason) + .build(); + } +} diff --git a/src/main/java/cn/violin/iam/service/UserProfileService.java b/src/main/java/cn/violin/iam/service/UserProfileService.java new file mode 100644 index 0000000..ffd5cc8 --- /dev/null +++ b/src/main/java/cn/violin/iam/service/UserProfileService.java @@ -0,0 +1,15 @@ +package cn.violin.iam.service; + +import cn.violin.core.entity.UserEntity; +import cn.violin.iam.dto.UpdateProfileRequest; + +import java.util.Optional; + +public interface UserProfileService { + + UserEntity getCurrent(); + + UserEntity updateCurrent(UpdateProfileRequest request); + + Optional findById(String userId); +} \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/service/UserService.java b/src/main/java/cn/violin/iam/service/UserService.java new file mode 100644 index 0000000..6cc2efd --- /dev/null +++ b/src/main/java/cn/violin/iam/service/UserService.java @@ -0,0 +1,12 @@ +package cn.violin.iam.service; + +import cn.violin.core.entity.UserEntity; + +import java.util.Optional; + +public interface UserService { + + Optional findById(String userId); + + UserEntity upsert(UserEntity user); +} \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/service/impl/UserProfileServiceImpl.java b/src/main/java/cn/violin/iam/service/impl/UserProfileServiceImpl.java new file mode 100644 index 0000000..adf5116 --- /dev/null +++ b/src/main/java/cn/violin/iam/service/impl/UserProfileServiceImpl.java @@ -0,0 +1,49 @@ +package cn.violin.iam.service.impl; + +import cn.violin.common.context.RequestContext; +import cn.violin.common.exception.ResourceNotFoundException; +import cn.violin.core.entity.UserEntity; +import cn.violin.iam.dto.UpdateProfileRequest; +import cn.violin.iam.mapper.UserMapper; +import cn.violin.iam.service.UserProfileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserProfileServiceImpl implements UserProfileService { + + private final UserMapper userMapper; + + @Override + @Transactional(readOnly = true) + public UserEntity getCurrent() { + String userId = RequestContext.getUserId(); + if (userId == null) throw new IllegalStateException("RequestContext.userId is not set"); + return userMapper.selectByUserId(userId) + .orElseThrow(() -> new ResourceNotFoundException(userId)); + } + + @Override + @Transactional + public UserEntity updateCurrent(UpdateProfileRequest request) { + UserEntity user = getCurrent(); + if (request.getUsername() != null && !request.getUsername().isBlank()) { + user.setUsername(request.getUsername().strip()); + } + if (request.getAvatarUrl() != null && !request.getAvatarUrl().isBlank()) { + user.setAvatarUrl(request.getAvatarUrl()); + } + userMapper.updateById(user); + return user; + } + + @Override + @Transactional(readOnly = true) + public Optional findById(String userId) { + return userMapper.selectByUserId(userId); + } +} \ No newline at end of file diff --git a/src/main/java/cn/violin/iam/service/impl/UserServiceImpl.java b/src/main/java/cn/violin/iam/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..17dac46 --- /dev/null +++ b/src/main/java/cn/violin/iam/service/impl/UserServiceImpl.java @@ -0,0 +1,43 @@ +package cn.violin.iam.service.impl; + +import cn.violin.common.context.RequestContext; +import cn.violin.core.entity.UserEntity; +import cn.violin.iam.mapper.UserMapper; +import cn.violin.iam.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserMapper userMapper; + + @Override + public Optional findById(String userId) { + return userMapper.selectByUserId(userId); + } + + @Override + public UserEntity upsert(UserEntity user) { + Optional existingOpt = userMapper.selectByUserId(user.getUserId()); + if (existingOpt.isEmpty()) { + if (user.getCustomerId() == null) { + user.setCustomerId(RequestContext.getCustomerId()); + } + userMapper.insert(user); + return user; + } + UserEntity existing = existingOpt.get(); + if (user.getCustomerId() != null) existing.setCustomerId(user.getCustomerId()); + if (user.getUsername() != null) existing.setUsername(user.getUsername()); + if (user.getEmail() != null) existing.setEmail(user.getEmail()); + if (user.getAvatarUrl() != null) existing.setAvatarUrl(user.getAvatarUrl()); + if (user.getLastLoginTime() != null) existing.setLastLoginTime(user.getLastLoginTime()); + userMapper.updateById(existing); + user.setId(existing.getId()); + return user; + } +} diff --git a/src/main/java/cn/violin/iam/sso/AuthentikConf.java b/src/main/java/cn/violin/iam/sso/AuthentikConf.java new file mode 100644 index 0000000..e97b8ad --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/AuthentikConf.java @@ -0,0 +1,48 @@ +package cn.violin.iam.sso; + +import cn.violin.iam.config.AuthentikProperties; +import lombok.Data; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Data +@Configuration +@Primary +@EnableConfigurationProperties(AuthentikProperties.class) +public class AuthentikConf { + + private final AuthentikProperties props; + + public AuthentikConf(AuthentikProperties props) { + this.props = props; + } + + public String getIssuer() { return props.getIssuer(); } + public String getClientId() { return props.getClientId(); } + public String getClientSecret() { return props.getClientSecret(); } + public String getRedirectUri() { return props.getRedirectUri(); } + public String getScope() { return props.getScope(); } + + public String getTokenUrl() { + return base() + "/application/o/token/"; + } + + public String getUserInfoUrl() { + return base() + "/application/o/userinfo/"; + } + + public String getAuthorizeUrl() { + return base() + "/application/o/authorize/"; + } + + private String base() { + String s = props.getIssuer(); + if (s == null) return ""; + s = s.trim(); + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } +} diff --git a/src/main/java/cn/violin/iam/sso/JwksController.java b/src/main/java/cn/violin/iam/sso/JwksController.java new file mode 100644 index 0000000..65f747c --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/JwksController.java @@ -0,0 +1,45 @@ +package cn.violin.iam.sso; + +import cn.violin.common.annotation.PassToken; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; + +/** + * Public JWKS endpoint. + * + *

Returns the active RSA public key set as JSON (RFC 7517). The endpoint + * requires no authentication ({@link PassToken} on the method) and short + * {@code Cache-Control: public, max-age=300} headers since the public key + * rarely rotates.

+ */ +@RestController +@RequiredArgsConstructor +@Tag(name = "JWKS") +public class JwksController { + + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private final RsaKeyProvider keys; + + @GetMapping(value = "/.well-known/jwks.json", + produces = MediaType.APPLICATION_JSON_VALUE) + @PassToken + public JsonNode jwks() { + JsonNode parsed = JSON.createArrayNode(); + try { + parsed = JSON.readTree("[" + keys.getJwk() + "]"); + } catch (Exception e) { + // Parsing RsaKeyProvider.getJwk() output should never fail in production + // but if it does we return an empty keyset so JWKS clients can react. + } + return JSON.createObjectNode() + .set("keys", parsed != null ? parsed : JSON.createArrayNode()); + } +} diff --git a/src/main/java/cn/violin/iam/sso/JwtIssuer.java b/src/main/java/cn/violin/iam/sso/JwtIssuer.java new file mode 100644 index 0000000..b533004 --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/JwtIssuer.java @@ -0,0 +1,38 @@ +package cn.violin.iam.sso; + +import cn.violin.iam.config.ViolinJwtProperties; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtIssuer { + + private final RsaKeyProvider keys; + private final ViolinJwtProperties props; + + public String generate(String subject, Map extraClaims) { + if (subject == null || subject.isBlank()) { + throw new IllegalArgumentException("subject must be non-blank"); + } + Instant now = Instant.now(); + var builder = Jwts.builder() + .header().keyId(keys.getKeyId()).type("JWT").and() + .id(UUID.randomUUID().toString()) + .issuer(props.getIssuer()) + .audience().add(props.getAudience()).and() + .subject(subject) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusMillis(props.getExpirationMs()))); + if (extraClaims != null) { + extraClaims.forEach(builder::claim); + } + return builder.signWith(keys.getPrivateKey(), Jwts.SIG.RS256).compact(); + } +} diff --git a/src/main/java/cn/violin/iam/sso/OAuthService.java b/src/main/java/cn/violin/iam/sso/OAuthService.java new file mode 100644 index 0000000..26ac3cf --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/OAuthService.java @@ -0,0 +1,10 @@ +package cn.violin.iam.sso; + +import cn.violin.iam.dto.AuthResponse; + +import java.io.IOException; + +public interface OAuthService { + + AuthResponse oidcAuthorize(String code, String state) throws IOException; +} diff --git a/src/main/java/cn/violin/iam/sso/OidcStateStore.java b/src/main/java/cn/violin/iam/sso/OidcStateStore.java new file mode 100644 index 0000000..f390351 --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/OidcStateStore.java @@ -0,0 +1,58 @@ +package cn.violin.iam.sso; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Server-side OIDC state store. + * + *

Single-use nonces backed by Caffeine with per-nonce {@link ReentrantLock} to + * make {@link #consume(String)} atomic across threads (Caffeine's + * {@code getIfPresent+invalidate} are not individually atomic).

+ * + *

Capacity bound: 100k in-flight nonces; expiry: 10 min (typical OIDC flow).

+ */ +@Component +public class OidcStateStore { + + private final Cache locks = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(10)) + .maximumSize(100_000) + .build(); + + private final Cache pending = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(10)) + .maximumSize(100_000) + .build(); + + public String issue() { + String nonce = UUID.randomUUID().toString(); + pending.put(nonce, nonce); + locks.put(nonce, new ReentrantLock()); + return nonce; + } + + public boolean consume(String nonce) { + if (nonce == null || nonce.isBlank()) return false; + ReentrantLock lock = locks.getIfPresent(nonce); + if (lock == null) { + return pending.getIfPresent(nonce) != null; + } + lock.lock(); + try { + String v = pending.getIfPresent(nonce); + if (v == null) return false; + pending.invalidate(nonce); + return true; + } finally { + lock.unlock(); + locks.invalidate(nonce); + } + } +} diff --git a/src/main/java/cn/violin/iam/sso/RsaKeyProvider.java b/src/main/java/cn/violin/iam/sso/RsaKeyProvider.java new file mode 100644 index 0000000..9346ef3 --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/RsaKeyProvider.java @@ -0,0 +1,150 @@ +package cn.violin.iam.sso; + +import cn.violin.iam.config.ViolinJwtProperties; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.util.StreamUtils; + +import jakarta.annotation.PostConstruct; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +@Configuration +@Slf4j +@EnableConfigurationProperties(ViolinJwtProperties.class) +public class RsaKeyProvider { + + @Getter + private RSAPrivateKey privateKey; + + @Getter + private RSAPublicKey publicKey; + + private final ViolinJwtProperties props; + + public RsaKeyProvider(ViolinJwtProperties props) { + this.props = props; + } + + @PostConstruct + public void init() { + boolean isLocal = props.isLocal(); + PrivateKey priv = null; + PublicKey pub = null; + String profile = props.getProfile(); + + if (!isLocal) { + String privPath = props.getPrivateKeyPath(); + String pubPath = props.getPublicKeyPath(); + if (privPath == null || privPath.isBlank()) { + throw new IllegalStateException( + "violin.jwt.private-key-path is required for profile=" + profile + + " (mount K8s Secret as PEM file)"); + } + if (pubPath == null || pubPath.isBlank()) { + throw new IllegalStateException( + "violin.jwt.public-key-path is required for profile=" + profile); + } + try { + priv = readPrivateKey(privPath); + pub = readPublicKey(pubPath); + } catch (Exception e) { + throw new IllegalStateException( + "failed to load JWT RSA key for profile=" + profile, e); + } + log.info("JWT RSA key pair loaded from PEM (profile={}, kid={})", profile, props.getKeyId()); + } else { + try { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair pair = gen.generateKeyPair(); + priv = pair.getPrivate(); + pub = pair.getPublic(); + log.warn("JWT RSA key pair generated in-memory (profile=local, NOT persisted). " + + "Restart will invalidate all tokens."); + } catch (Exception e) { + throw new IllegalStateException("failed to generate RSA key pair", e); + } + } + + if (!(priv instanceof RSAPrivateKey) || !(pub instanceof RSAPublicKey)) { + throw new IllegalStateException("key pair must be RSA"); + } + this.privateKey = (RSAPrivateKey) priv; + this.publicKey = (RSAPublicKey) pub; + } + + private static PrivateKey readPrivateKey(String path) throws Exception { + Resource resource = new FileSystemResource(path); + String pem = readAll(resource); + String b64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + byte[] der = Base64.getDecoder().decode(b64); + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); + } + + private static PublicKey readPublicKey(String path) throws Exception { + Resource resource = new FileSystemResource(path); + String pem = readAll(resource); + String b64 = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("-----BEGIN RSA PUBLIC KEY-----", "") + .replace("-----END RSA PUBLIC KEY-----", "") + .replaceAll("\\s+", ""); + byte[] der = Base64.getDecoder().decode(b64); + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der)); + } + + private static String readAll(Resource r) throws Exception { + try (InputStream in = r.getInputStream()) { + return StreamUtils.copyToString(in, StandardCharsets.UTF_8); + } + } + + public String getKeyId() { + return props.getKeyId(); + } + + public String getJwk() { + return "{" + + "\"kty\":\"RSA\"," + + "\"kid\":\"" + props.getKeyId() + "\"," + + "\"use\":\"sig\"," + + "\"alg\":\"RS256\"," + + "\"n\":\"" + base64Url(publicKey.getModulus().toByteArray()) + "\"," + + "\"e\":\"" + base64Url(publicKey.getPublicExponent().toByteArray()) + "\"" + + "}"; + } + + private static String base64Url(byte[] bytes) { + byte[] trimmed = stripLeadingZeros(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(trimmed); + } + + private static byte[] stripLeadingZeros(byte[] b) { + int i = 0; + while (i < b.length - 1 && b[i] == 0) i++; + byte[] out = new byte[b.length - i]; + System.arraycopy(b, i, out, 0, out.length); + return out; + } +} diff --git a/src/main/java/cn/violin/iam/sso/impl/OAuthServiceImpl.java b/src/main/java/cn/violin/iam/sso/impl/OAuthServiceImpl.java new file mode 100644 index 0000000..ad0af5b --- /dev/null +++ b/src/main/java/cn/violin/iam/sso/impl/OAuthServiceImpl.java @@ -0,0 +1,162 @@ +package cn.violin.iam.sso.impl; + +import cn.violin.common.exception.BusinessException; +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.core.entity.UserEntity; +import cn.violin.iam.dto.AuthResponse; +import cn.violin.iam.service.UserService; +import cn.violin.iam.sso.AuthentikConf; +import cn.violin.iam.sso.JwtIssuer; +import cn.violin.iam.sso.OAuthService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.json.JsonMapper; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; + +@Service +@Slf4j +public class OAuthServiceImpl implements OAuthService { + + private static final JsonMapper JSON = JsonMapper.builder().build(); + + private final AuthentikConf authentikConf; + private final UserService userService; + private final JwtIssuer jwtIssuer; + private final CloseableHttpClient httpClient; + + public OAuthServiceImpl(AuthentikConf authentikConf, + UserService userService, + JwtIssuer jwtIssuer, + @Qualifier("violinHttpClient") CloseableHttpClient httpClient) { + this.authentikConf = authentikConf; + this.userService = userService; + this.jwtIssuer = jwtIssuer; + this.httpClient = httpClient; + } + + @Override + @Transactional + public AuthResponse oidcAuthorize(String code, String state) throws IOException { + if (code == null || code.isBlank()) { + throw new BusinessException("OAUTH_CODE_INVALID", "authorization code is empty"); + } + if (state == null || state.isBlank()) { + throw new UnauthorizedException("state parameter missing"); + } + + try { + String accessToken = exchangeCode(code); + JsonNode userInfo = fetchUserInfo(accessToken); + String sub = textOrNull(userInfo, "sub"); + if (sub == null) { + throw new BusinessException("OAUTH_USERINFO_INVALID", "Authentik returned no sub"); + } + + String username = pickFirstNonNull( + textOrNull(userInfo, "preferred_username"), + textOrNull(userInfo, "name"), + sub); + String email = textOrNull(userInfo, "email"); + String avatarUrl = textOrNull(userInfo, "picture"); + + UserEntity existing = userService.findById(sub).orElse(null); + if (existing == null) { + log.info("OIDC first login for sub={}, not yet enrolled", sub); + throw new BusinessException("OAUTH_USER_NOT_ENROLLED", + "user has no t_user record; enrollment required before login"); + } + + if (existing.getCustomerId() == null || existing.getCustomerId().isBlank()) { + log.warn("user {} has no customer binding; deny login", sub); + throw new BusinessException("OAUTH_NO_CUSTOMER_BINDING", + "user has no customer binding"); + } + + OffsetDateTime now = OffsetDateTime.now(); + if (username != null) existing.setUsername(username); + if (email != null) existing.setEmail(email); + if (avatarUrl != null) existing.setAvatarUrl(avatarUrl); + existing.setLastLoginTime(now); + userService.upsert(existing); + + Map claims = new HashMap<>(); + claims.put("email", email); + claims.put("name", username); + claims.put("picture", avatarUrl); + claims.put("customerId", existing.getCustomerId()); + String myToken = jwtIssuer.generate(sub, claims); + + log.info("OIDC login success: sub={}, customerId={}", sub, existing.getCustomerId()); + return new AuthResponse(myToken, sub, username, email, avatarUrl); + } catch (IOException e) { + throw new BusinessException("OAUTH_IO_ERROR", "I/O error during OIDC: " + e.getMessage()); + } + } + + private String exchangeCode(String code) throws IOException { + HttpPost tokenRequest = new HttpPost(authentikConf.getTokenUrl()); + tokenRequest.setHeader("Content-Type", "application/x-www-form-urlencoded"); + String body = "grant_type=authorization_code" + + "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(authentikConf.getRedirectUri(), StandardCharsets.UTF_8) + + "&client_id=" + URLEncoder.encode(authentikConf.getClientId(), StandardCharsets.UTF_8) + + "&client_secret=" + URLEncoder.encode(authentikConf.getClientSecret(), StandardCharsets.UTF_8); + tokenRequest.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); + + var response = httpClient.execute(tokenRequest); + if (response.getStatusLine().getStatusCode() != 200) { + String err = EntityUtils.toString(response.getEntity()); + throw new BusinessException("OAUTH_TOKEN_FAILED", + "Token exchange failed: HTTP " + response.getStatusLine().getStatusCode() + " body=" + err); + } + JsonNode root = JSON.readTree(EntityUtils.toString(response.getEntity())); + String token = textOrNull(root, "access_token"); + if (token == null) { + throw new BusinessException("OAUTH_TOKEN_FAILED", + "Token exchange returned no access_token field"); + } + return token; + } + + private JsonNode fetchUserInfo(String accessToken) throws IOException { + HttpGet userInfoRequest = new HttpGet(authentikConf.getUserInfoUrl()); + userInfoRequest.setHeader("Authorization", "Bearer " + accessToken); + var response = httpClient.execute(userInfoRequest); + if (response.getStatusLine().getStatusCode() != 200) { + String err = EntityUtils.toString(response.getEntity()); + throw new BusinessException("OAUTH_USERINFO_FAILED", + "UserInfo failed: HTTP " + response.getStatusLine().getStatusCode() + " body=" + err); + } + return JSON.readTree(EntityUtils.toString(response.getEntity())); + } + + private static String textOrNull(JsonNode node, String field) { + if (node == null) return null; + JsonNode v = node.get(field); + if (v == null || v.isNull() || v.isMissingNode()) return null; + if (!v.isValueNode()) return null; + String s = v.asText(); + return (s == null || s.isBlank()) ? null : s; + } + + private static String pickFirstNonNull(String... values) { + for (String v : values) { + if (v != null && !v.isBlank()) return v; + } + return null; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..d4132bd --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,30 @@ +spring: + datasource: + url: jdbc:postgresql://postgres:5432/violin + username: ${DB_USERNAME:violin} + password: ${DB_PASSWORD:violin} + driver-class-name: org.postgresql.Driver + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +violin: + iam: + service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:} + service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:} + service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller} + jwt: + issuer: ${VIOLIN_JWT_ISSUER:violin-iam} + audience: ${VIOLIN_JWT_AUDIENCE:violin-services} + expiration-ms: ${VIOLIN_JWT_EXPIRATION_MS:86400000} + key-id: ${VIOLIN_JWT_KEY_ID:violin-iam-dev-1} + private-key-path: ${VIOLIN_JWT_PRIVATE_KEY_PATH:/etc/violin/keys/private.pem} + public-key-path: ${VIOLIN_JWT_PUBLIC_KEY_PATH:/etc/violin/keys/public.pem} + jwks-url: ${VIOLIN_JWT_JWKS_URL:http://violin-iam.violin-home.cn/violin-iam/.well-known/jwks.json} + refresh-seconds: ${VIOLIN_JWT_REFRESH_SECONDS:300} + clock-skew-seconds: ${VIOLIN_JWT_CLOCK_SKEW_SECONDS:30} + +logging: + level: + cn.violin: INFO diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..1a0b0de --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,22 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:postgres}:${DB_PORT:5432}/${DB_NAME:violin} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + flyway: + enabled: true + baseline-on-migrate: true + locations: classpath:db/migration + +violin: + jwt: + private-key-path: ${VIOLIN_JWT_PRIVATE_KEY_PATH:/etc/violin/keys/private.pem} + public-key-path: ${VIOLIN_JWT_PUBLIC_KEY_PATH:/etc/violin/keys/public.pem} + key-id: ${VIOLIN_JWT_KEY_ID:violin-iam-prod-1} + +logging: + level: + cn.violin: INFO + cn.violin.core.security: WARN + cn.violin.iam.sso: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..89b896b --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,74 @@ +server: + port: 8080 + servlet: + context-path: /violin-iam + shutdown: graceful + +spring: + application: + name: violin-iam + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + jackson: + default-property-inclusion: non_null + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + lifecycle: + timeout-per-shutdown-phase: 30s + +violin: + jwt: + issuer: ${VIOLIN_JWT_ISSUER:violin-iam} + audience: ${VIOLIN_JWT_AUDIENCE:violin-services} + expiration-ms: ${VIOLIN_JWT_EXPIRATION_MS:86400000} + key-id: ${VIOLIN_JWT_KEY_ID:violin-iam-1} + private-key-path: ${VIOLIN_JWT_PRIVATE_KEY_PATH:} + public-key-path: ${VIOLIN_JWT_PUBLIC_KEY_PATH:} + jwks-url: ${VIOLIN_JWT_JWKS_URL:http://localhost:8080/violin-iam/.well-known/jwks.json} + refresh-seconds: ${VIOLIN_JWT_REFRESH_SECONDS:300} + clock-skew-seconds: ${VIOLIN_JWT_CLOCK_SKEW_SECONDS:30} + profile: ${SPRING_PROFILES_ACTIVE:local} + auth: + exclude-paths: /error + anonymous-message-code: AUTHORIZATION_MISSING + iam: + service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller} + url: ${VIOLIN_IAM_URL:http://localhost:8080} + context-path: ${VIOLIN_IAM_CONTEXT_PATH:/violin-iam} + service-token-secret: ${VIOLIN_IAM_SERVICE_TOKEN_SECRET:} + service-allowlist: ${VIOLIN_IAM_SERVICE_ALLOWLIST:} + profile: ${SPRING_PROFILES_ACTIVE:local} + +authentik: + issuer: ${AUTHENTIK_ISSUER:} + client-id: ${AUTHENTIK_CLIENT_ID:} + client-secret: ${AUTHENTIK_CLIENT_SECRET:} + redirect-uri: ${AUTHENTIK_REDIRECT_URI:} + scope: ${AUTHENTIK_SCOPE:openid profile email} + +management: + endpoints: + web: + exposure: + include: health,prometheus + endpoint: + health: + show-details: never + probes: + enabled: true + group: + liveness: + include: livenessState + readiness: + include: readinessState,db + info: + enabled: false + info: + env: + enabled: false + keys: [] + +info: + app: + name: "@project.name@" + version: "@project.version@" diff --git a/src/test/java/cn/violin/iam/security/ServiceAllowlistTest.java b/src/test/java/cn/violin/iam/security/ServiceAllowlistTest.java new file mode 100644 index 0000000..6c74bd5 --- /dev/null +++ b/src/test/java/cn/violin/iam/security/ServiceAllowlistTest.java @@ -0,0 +1,77 @@ +package cn.violin.iam.security; + +import cn.violin.common.exception.UnauthorizedException; +import cn.violin.iam.config.ServiceAuthProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ServiceAllowlistTest { + + private ServiceAuthProperties props; + + @BeforeEach + void setUp() { + props = new ServiceAuthProperties(); + props.setServiceAllowlist("callerA=tA:tB,callerB=*,callerC=tC"); + } + + @Test + void resolvesTenantWhenRequestedMatchesAllowlist() { + ServiceAllowlist sl = new ServiceAllowlist(props); + assertEquals("tA", sl.resolveTenant("callerA", "tA")); + assertEquals("tB", sl.resolveTenant("callerA", "tB")); + } + + @Test + void rejectsTenantNotInAllowlist() { + ServiceAllowlist sl = new ServiceAllowlist(props); + assertThrows(UnauthorizedException.class, + () -> sl.resolveTenant("callerA", "tZ")); + } + + @Test + void rejectsUnknownService() { + ServiceAllowlist sl = new ServiceAllowlist(props); + assertThrows(UnauthorizedException.class, + () -> sl.resolveTenant("ghost", "tA")); + } + + @Test + void clusterWideRequiresExplicitTenant() { + ServiceAllowlist sl = new ServiceAllowlist(props); + // cluster-wide must accept any tenant explicitly named + assertEquals("anyCustomer", sl.resolveTenant("callerB", "anyCustomer")); + // but reject blank + assertThrows(UnauthorizedException.class, + () -> sl.resolveTenant("callerB", "")); + assertThrows(UnauthorizedException.class, + () -> sl.resolveTenant("callerB", null)); + } + + @Test + void singleTenantDefaultsWhenNoRequestedTenant() { + ServiceAllowlist sl = new ServiceAllowlist(props); + // callerC only has tC; if request omits customerId we should fall back to tC + assertEquals("tC", sl.resolveTenant("callerC", null)); + assertEquals("tC", sl.resolveTenant("callerC", "")); + } + + @Test + void malformedConfigThrowsAtConstruction() { + ServiceAuthProperties bad = new ServiceAuthProperties(); + bad.setServiceAllowlist("callerA==tA"); + assertThrows(IllegalArgumentException.class, () -> new ServiceAllowlist(bad)); + } + + @Test + void emptyAllowlistRejectsEverything() { + ServiceAuthProperties empty = new ServiceAuthProperties(); + empty.setServiceAllowlist(""); + ServiceAllowlist sl = new ServiceAllowlist(empty); + assertThrows(UnauthorizedException.class, + () -> sl.resolveTenant("anything", "tA")); + } +} diff --git a/src/test/java/cn/violin/iam/security/ServiceAuthValidatorTest.java b/src/test/java/cn/violin/iam/security/ServiceAuthValidatorTest.java new file mode 100644 index 0000000..7bda6c7 --- /dev/null +++ b/src/test/java/cn/violin/iam/security/ServiceAuthValidatorTest.java @@ -0,0 +1,69 @@ +package cn.violin.iam.security; + +import cn.violin.iam.config.ServiceAuthProperties; +import org.junit.jupiter.api.Test; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; + +import static org.junit.jupiter.api.Assertions.*; + +class ServiceAuthValidatorTest { + + private static final String HEX = "0011223344556677889900aabbccddeeff"; + + private static ServiceAuthValidator makeValidator(String hex, String profile) { + ServiceAuthProperties props = new ServiceAuthProperties(); + props.setServiceTokenSecret(hex); + props.setProfile(profile); + return new ServiceAuthValidator(props); + } + + @Test + void sign_isDeterministic() { + ServiceAuthValidator a = makeValidator(HEX, "dev"); + ServiceAuthValidator b = makeValidator(HEX, "dev"); + long ts = 1_700_000_000_000L; + assertEquals(a.sign("svc1", ts, "nonceA"), b.sign("svc1", ts, "nonceA")); + } + + @Test + void sign_changesWithInputs() { + ServiceAuthValidator v = makeValidator(HEX, "dev"); + long ts = 1_700_000_000_000L; + assertNotEquals(v.sign("svc1", ts, "n1"), v.sign("svc2", ts, "n1")); + assertNotEquals(v.sign("svc1", ts, "n1"), v.sign("svc1", ts, "n2")); + assertNotEquals(v.sign("svc1", ts, "n1"), v.sign("svc1", ts + 1L, "n1")); + } + + @Test + void secretMustBeHex() { + assertThrows(Exception.class, + () -> makeValidator("not-hex!!", "dev")); + } + + @Test + void matchesHmacSha256Spec() throws Exception { + byte[] secret = HexFormat.of().parseHex(HEX); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + byte[] raw = mac.doFinal("svc1:1700000000000:nonceA".getBytes(StandardCharsets.UTF_8)); + String expected = HexFormat.of().formatHex(raw); + + ServiceAuthValidator v = makeValidator(HEX, "dev"); + assertEquals(expected, v.sign("svc1", 1_700_000_000_000L, "nonceA")); + } + + @Test + void localProfileAllowsBlankSecret() { + assertDoesNotThrow(() -> makeValidator("", "local")); + } + + @Test + void nonLocalProfileRejectsBlankSecret() { + assertThrows(IllegalStateException.class, + () -> makeValidator("", "prod")); + } +} diff --git a/src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceCacheKeyTest.java b/src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceCacheKeyTest.java new file mode 100644 index 0000000..ba4b302 --- /dev/null +++ b/src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceCacheKeyTest.java @@ -0,0 +1,86 @@ +package cn.violin.iam.service; + +import cn.violin.iam.dto.SubjectAccessReviewRequest; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * Verifies the SAR cache key behaves as a true SHA-256 hash: deterministic, + * tenant-sensitive, and collision-resistant across reordering. + * + *

The method under test is private; we exercise it via the {@link + * PolicyRuleParser} surface since both pieces are exercised by the same SAR + * hot path.

+ */ +class SubjectAccessReviewServiceCacheKeyTest { + + private SubjectAccessReviewService sar() { + return new SubjectAccessReviewService(null, null, null, null, null); + } + + @SuppressWarnings("unchecked") + private Set keys(SubjectAccessReviewService svc, SubjectAccessReviewRequest req) throws Exception { + Method m = SubjectAccessReviewService.class.getDeclaredMethod("buildCacheKey", SubjectAccessReviewRequest.class); + m.setAccessible(true); + Set keys = new HashSet<>(); + keys.add((String) m.invoke(svc, req)); + return keys; + } + + private SubjectAccessReviewRequest req(String user, String customer, String resource, String verb) { + return SubjectAccessReviewRequest.builder() + .userSub(user) + .customerId(customer) + .apiGroup("violin") + .resource(resource) + .verb(verb) + .build(); + } + + @Test + void cacheKeyIsDeterministic() throws Exception { + SubjectAccessReviewService svc = sar(); + Set a = keys(svc, req("u1", "tA", "user", "list")); + Set b = keys(svc, req("u1", "tA", "user", "list")); + assertEquals(a, b); + } + + @Test + void cacheKeyIsTenantSensitive() throws Exception { + SubjectAccessReviewService svc = sar(); + Set a = keys(svc, req("u1", "tA", "user", "list")); + Set b = keys(svc, req("u1", "tB", "user", "list")); + assertNotEquals(a, b, + "different customerId must produce different cache keys"); + } + + @Test + void cacheKeyIsUserSensitive() throws Exception { + SubjectAccessReviewService svc = sar(); + Set a = keys(svc, req("u1", "tA", "user", "list")); + Set b = keys(svc, req("u2", "tA", "user", "list")); + assertNotEquals(a, b); + } + + @Test + void cacheKeyIsActionSensitive() throws Exception { + SubjectAccessReviewService svc = sar(); + Set a = keys(svc, req("u1", "tA", "user", "list")); + Set b = keys(svc, req("u1", "tA", "user", "get")); + assertNotEquals(a, b); + } + + @Test + void emptyTenantDoesNotCollideWithDifferentUser() throws Exception { + SubjectAccessReviewService svc = sar(); + Set a = keys(svc, req("u1", null, "user", "list")); + Set b = keys(svc, req("u2", null, "user", "list")); + assertNotEquals(a, b); + } +} diff --git a/src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceVerifiedGateTest.java b/src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceVerifiedGateTest.java new file mode 100644 index 0000000..fbe1140 --- /dev/null +++ b/src/test/java/cn/violin/iam/service/SubjectAccessReviewServiceVerifiedGateTest.java @@ -0,0 +1,40 @@ +package cn.violin.iam.service; + +import cn.violin.iam.dto.SubjectAccessReviewRequest; +import cn.violin.iam.dto.SubjectAccessReviewResponse; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies the customerId-verified gate (Round-5 P0-1): SAR must fail-closed + * when caller claims a customerId without proving its trusted provenance. + * + *

The constructor passes null mappers/parser; the verified check short-circuits + * before any DB lookup so this test is fully self-contained.

+ * + *

The opposite case (verified=true, accepted) requires a real DB-backed SAR + * stub and belongs in an integration test, not here.

+ */ +class SubjectAccessReviewServiceVerifiedGateTest { + + @Test + void unverifiedCustomerIdIsRejected() { + SubjectAccessReviewService svc = new SubjectAccessReviewService(null, null, null, null, null); + SubjectAccessReviewResponse resp = svc.check(SubjectAccessReviewRequest.builder() + .userSub("user-1") + .customerId("tenant-A") + .customerVerified(false) + .apiGroup("violin") + .resource("user") + .verb("list") + .build()); + assertFalse(Boolean.TRUE.equals(resp.getAllowed()), + "unverified customerId must NOT receive allow; got: " + resp.getAllowed()); + assertNotNull(resp.getReason()); + assertEquals("customerId is not verified; tenant scope cannot be enforced", + resp.getReason()); + } +} diff --git a/src/test/java/cn/violin/iam/sso/OidcStateStoreContractTest.java b/src/test/java/cn/violin/iam/sso/OidcStateStoreContractTest.java new file mode 100644 index 0000000..9392150 --- /dev/null +++ b/src/test/java/cn/violin/iam/sso/OidcStateStoreContractTest.java @@ -0,0 +1,46 @@ +package cn.violin.iam.sso; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class OidcStateStoreContractTest { + + @Test + void flow_issueConsumeOnce() { + OidcStateStore store = new OidcStateStore(); + String nonce = store.issue(); + + // 模拟回调到达:必须消费一次 + assertTrue(store.consume(nonce), "first consume must succeed"); + + // 重放同一 nonce 必须拒绝 + assertFalse(store.consume(nonce), "replay must be detected"); + } + + @Test + void flow_blankNonceRejected() { + OidcStateStore store = new OidcStateStore(); + store.issue(); + assertFalse(store.consume("")); + assertFalse(store.consume(" ")); + } + + @Test + void flow_manyParallelCallbacksCompete() throws InterruptedException { + OidcStateStore store = new OidcStateStore(); + String nonce = store.issue(); + int n = 16; + Thread[] threads = new Thread[n]; + final boolean[] ok = new boolean[n]; + for (int i = 0; i < n; i++) { + final int idx = i; + threads[i] = new Thread(() -> ok[idx] = store.consume(nonce)); + } + for (Thread t : threads) t.start(); + for (Thread t : threads) t.join(); + int winners = 0; + for (boolean b : ok) if (b) winners++; + assertEquals(1, winners, "exactly one concurrent consumer wins"); + } +} diff --git a/src/test/java/cn/violin/iam/sso/OidcStateStoreTest.java b/src/test/java/cn/violin/iam/sso/OidcStateStoreTest.java new file mode 100644 index 0000000..973dd30 --- /dev/null +++ b/src/test/java/cn/violin/iam/sso/OidcStateStoreTest.java @@ -0,0 +1,47 @@ +package cn.violin.iam.sso; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class OidcStateStoreTest { + + private OidcStateStore store; + + @BeforeEach + void setUp() { + store = new OidcStateStore(); + } + + @Test + void issue_returnsUuidLikeNonce() { + String nonce = store.issue(); + assertNotNull(nonce); + assertFalse(nonce.isBlank()); + } + + @Test + void consume_validNonce_returnsTrueAndIsOneShot() { + String nonce = store.issue(); + assertTrue(store.consume(nonce)); + assertFalse(store.consume(nonce)); + } + + @Test + void consume_unknownNonce_returnsFalse() { + assertFalse(store.consume("nonexistent")); + assertFalse(store.consume("")); + assertFalse(store.consume(null)); + } + + @Test + void twoIssuedNoncesIndependent() { + String a = store.issue(); + String b = store.issue(); + assertNotEquals(a, b); + assertTrue(store.consume(a)); + assertFalse(store.consume(a)); + assertTrue(store.consume(b)); + } +}