feat: first commit

This commit is contained in:
simple321vip
2026-07-06 22:31:15 +08:00
commit a0ebf1dd07
56 changed files with 2784 additions and 0 deletions
@@ -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 &lt;calling service identity&gt;
* X-Service-Timestamp &lt;millis since epoch&gt;
* X-Service-Nonce &lt;one-shot UUID&gt;
* 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;
}
}
+30
View File
@@ -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
+22
View File
@@ -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
+74
View File
@@ -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@"