feat: first commit
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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).</p>
|
||||
*/
|
||||
@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; }
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, String>> 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<String, String> body = new HashMap<>();
|
||||
body.put("authorizeUrl", url);
|
||||
body.put("state", state);
|
||||
return ApiResponse.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/auth/oidc/callback")
|
||||
@PassToken
|
||||
public ApiResponse<AuthResponse> 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<Map<String, String>> 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<String, String> body = new HashMap<>();
|
||||
body.put("revokedJti", jti == null ? "" : jti);
|
||||
return ApiResponse.ok(body);
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ApiResponse<CurrentUserResponse> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<CurrentUserResponse> 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()));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> matchedRoles;
|
||||
private List<String> allowedResourceNames;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package cn.violin.iam.entity;
|
||||
|
||||
public enum SubjectKind {
|
||||
USER,
|
||||
GROUP,
|
||||
SERVICE_ACCOUNT
|
||||
}
|
||||
@@ -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<PolicyRuleEntity> {
|
||||
}
|
||||
@@ -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<RoleBindingEntity> {
|
||||
}
|
||||
@@ -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<RoleEntity> {
|
||||
}
|
||||
@@ -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<RolePolicyEntity> {
|
||||
}
|
||||
@@ -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<UserEntity> {
|
||||
|
||||
@Select("SELECT * FROM t_user WHERE user_id = #{userId} AND is_deleted = FALSE LIMIT 1")
|
||||
Optional<UserEntity> selectByUserId(String userId);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>This class is itself a Spring bean (annotated {@link Configuration}); do not
|
||||
* expose redundant {@code @Bean} factory methods that return {@code this}.</p>
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class ServiceAllowlist {
|
||||
|
||||
private static final String CLUSTER_WIDE = "*";
|
||||
|
||||
private final Map<String, Set<String>> allowlist;
|
||||
|
||||
public ServiceAllowlist(ServiceAuthProperties props) {
|
||||
Map<String, Set<String>> 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<String> 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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Verifies HMAC-SHA256 triple-header tokens:
|
||||
* <pre>
|
||||
* 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))
|
||||
* </pre>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
@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<String, Boolean> 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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
*
|
||||
* <p>Two flavors are exposed:
|
||||
* <ul>
|
||||
* <li>{@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.</li>
|
||||
* <li>{@link #parseArray(String)} returns a {@code String[]} (empty for blank input,
|
||||
* empty array for malformed JSON at WARN level). For non-critical read paths.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PolicyRuleParser {
|
||||
|
||||
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Cache<String, String[]> 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}.
|
||||
* <ul>
|
||||
* <li>blank input → {@code Optional.empty()}</li>
|
||||
* <li>valid JSON → {@code Optional.of(set)}</li>
|
||||
* <li>malformed JSON → throws {@link IllegalStateException}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public Optional<Set<String>> 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[]}.
|
||||
* <ul>
|
||||
* <li>blank input → empty array</li>
|
||||
* <li>valid JSON → parsed</li>
|
||||
* <li>malformed JSON → empty array (WARN logged)</li>
|
||||
* </ul>
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, SubjectAccessReviewResponse> allowCache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(Duration.ofSeconds(30))
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, SubjectAccessReviewResponse> 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<RoleBindingEntity> bindings = roleBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<RoleBindingEntity>()
|
||||
.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<String> roleIds = bindings.stream().map(RoleBindingEntity::getRoleId).collect(Collectors.toList());
|
||||
|
||||
if (request.getCustomerId() != null && !request.getCustomerId().isBlank()) {
|
||||
List<RoleEntity> roles = roleMapper.selectBatchIds(roleIds);
|
||||
Set<String> 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<RolePolicyEntity> rolePolicies = rolePolicyMapper.selectList(
|
||||
new LambdaQueryWrapper<RolePolicyEntity>().in(RolePolicyEntity::getRoleId, roleIds));
|
||||
if (rolePolicies.isEmpty()) {
|
||||
return denied("user has roles but no policies attached");
|
||||
}
|
||||
|
||||
List<String> policyIds = rolePolicies.stream().map(RolePolicyEntity::getPolicyId).collect(Collectors.toList());
|
||||
Map<String, PolicyRuleEntity> policyMap = policyRuleMapper.selectBatchIds(policyIds).stream()
|
||||
.collect(Collectors.toMap(PolicyRuleEntity::getId, Function.identity()));
|
||||
Map<String, RoleEntity> roleMap = roleMapper.selectBatchIds(roleIds).stream()
|
||||
.collect(Collectors.toMap(RoleEntity::getId, Function.identity()));
|
||||
|
||||
Map<String, List<String>> policyToRoles = rolePolicies.stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
RolePolicyEntity::getPolicyId,
|
||||
Collectors.mapping(RolePolicyEntity::getRoleId, Collectors.toList())));
|
||||
|
||||
Set<String> matchedRoleNames = new HashSet<>();
|
||||
Set<String> 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<String> 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<PolicyRuleEntity> listPolicies(String userSub, String customerId) {
|
||||
if (userSub == null) return Collections.emptyList();
|
||||
|
||||
List<RoleBindingEntity> bindings = roleBindingMapper.selectList(
|
||||
new LambdaQueryWrapper<RoleBindingEntity>()
|
||||
.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<String> roleIds = bindings.stream().map(RoleBindingEntity::getRoleId).collect(Collectors.toList());
|
||||
if (customerId != null && !customerId.isBlank()) {
|
||||
List<RoleEntity> roles = roleMapper.selectBatchIds(roleIds);
|
||||
Set<String> 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<RolePolicyEntity> rolePolicies = rolePolicyMapper.selectList(
|
||||
new LambdaQueryWrapper<RolePolicyEntity>().in(RolePolicyEntity::getRoleId, roleIds));
|
||||
if (rolePolicies.isEmpty()) return Collections.emptyList();
|
||||
|
||||
List<String> policyIds = rolePolicies.stream().map(RolePolicyEntity::getPolicyId).collect(Collectors.toList());
|
||||
return policyRuleMapper.selectBatchIds(policyIds);
|
||||
}
|
||||
|
||||
public List<PolicyRuleEntity> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<UserEntity> findById(String userId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.violin.iam.service;
|
||||
|
||||
import cn.violin.core.entity.UserEntity;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserService {
|
||||
|
||||
Optional<UserEntity> findById(String userId);
|
||||
|
||||
UserEntity upsert(UserEntity user);
|
||||
}
|
||||
@@ -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<UserEntity> findById(String userId) {
|
||||
return userMapper.selectByUserId(userId);
|
||||
}
|
||||
}
|
||||
@@ -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<UserEntity> findById(String userId) {
|
||||
return userMapper.selectByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserEntity upsert(UserEntity user) {
|
||||
Optional<UserEntity> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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).</p>
|
||||
*
|
||||
* <p>Capacity bound: 100k in-flight nonces; expiry: 10 min (typical OIDC flow).</p>
|
||||
*/
|
||||
@Component
|
||||
public class OidcStateStore {
|
||||
|
||||
private final Cache<String, ReentrantLock> locks = Caffeine.newBuilder()
|
||||
.expireAfterWrite(Duration.ofMinutes(10))
|
||||
.maximumSize(100_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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@"
|
||||
Reference in New Issue
Block a user