refactor: apply ConfigurationProperties

This commit is contained in:
simple321vip
2026-07-07 09:59:32 +08:00
parent 10cded53dd
commit 93c5dff50b
9 changed files with 420 additions and 67 deletions
+138
View File
@@ -9,3 +9,141 @@
file tools or Python instead.
2. Use English in code. Avoid Chinese, Japanese, or any other non-ASCII
natural language inside source files.
## Boot rules
The very first time I ran `java -cp ... cn.violin.iam.ViolinIamApplication`,
Spring failed with "Parameter 0 of constructor in
`cn.violin.core.config.WebMvcConfig` required a bean of type
`cn.violin.core.interceptor.AuthenticationInterceptor` that could not be
found". The unit-test suite was green but had never actually booted the
context. Concrete gotchas to fix:
- **`@SpringBootApplication` MUST set `scanBasePackages = {"cn.violin"}`**
explicitly. The default only scans the app's own sub-package
(`cn.violin.iam`), which excludes `cn.violin.core.interceptor.AuthenticationInterceptor`,
`CurrentUserHandlerMethodArgumentResolver`, `TraceIdFilter`, and other
framework beans. Spring Boot auto-config registers the
`@AutoConfiguration` classes themselves, but their `@Bean` dependencies
that are not themselves exposed via another `@Bean` still need component-scan
coverage. Compare with `violin-auth`, which has always used
`scanBasePackages = {"cn.violin"}`; it never tripped over this.
- **`@MapperScan("cn.violin.*.mapper")` — never use bare `cn.violin`.**
The broad form hoovers up every interface in the project, including
framework-internal ones like `JwksClient`, and MyBatis then complains
at runtime:
`Invalid bound statement (not found): cn.violin.core.security.JwksClient.loadKeys`.
Narrowing the pattern prevents MyBatis from treating non-mapper
interfaces as proxies.
- **`@Service` impls whose interface is in the same package should be
marked `@Primary` when `scanBasePackages` is widened.** Once
`cn.violin.*` is scanned, Spring ends up holding two `BeanDefinition`s
of the same bean type (the impl + some derived candidate), and
`@Autowired` of the interface fails with `NoUniqueBeanDefinitionException:
expected single matching bean but found 2: OAuthServiceImpl,OAuthService`.
Adding `@Primary` to the impl collapses that to a single candidate.
- **Spring 6 forbids `@PostConstruct` methods with parameters.**
`SnowflakeId.init(SnowflakeProperties props)` looked innocuous but
`IllegalStateException: Lifecycle annotation requires a no-arg method`
hit on `@SpringBootTest`. Use `InitializingBean` + `@Autowired` for
the setter (properties injection remains valid via
`wireProperties`). Same pattern applies to any other singleton that
needs configuration at boot.
- **`@ConfigurationProperties` on Java `record` types needs
`@ConstructorBinding`** when the canonical constructor does not match
Spring's expected discovery. Without it, Spring fails with
`No default constructor found`.
- **Adding `@SpringBootTest` early catches all four of these.** Place a
smoke test (`ApplicationContextLoadsTest`) in
`src/test/java/.../boot/` and run `mvn test`. If the unit tests are
green but the boot test fails, the bug is precisely the kind that
silently passes when scanning only `@Component`-annotated classes
but blows up at full startup.
## Design notes (do not skip)
## Design notes (do not skip)
### JWT revocation propagation — what we tried, what we picked, and why
The naive question "should we drop `customerId` from the JWT claim so
downstream must call IAM every request?" was the wrong frame. Below is
the actual decision tree we walked.
#### Problem
A user's `t_user.customer_id` is migrated from tenant A to tenant B. The
JWT issued at login still carries `customerId=A`. Without propagation
the user keeps operating under A's RBAC scope (and SAR cache) for the
remainder of the token TTL.
#### Industry patterns surveyed
1. **Short TTL + RFC 7662 introspection** (Google / AWS / GitHub /
Slack). Token TTL = 5-15 min; each request consults an introspection
endpoint (with local cache) to check the token is still live.
Revocation propagates in <1 s via shared cache / pub-sub.
2. **`revokedCustomerBindings` cache** (medium-scale).
`Cache<userId, oldCustomerId>` with TTL covering the longest possible
token lifetime. `UserService.changeCustomer(...)` writes the DB row
then publishes `(userId, oldCustomerId)` into the cache. The cache
TTL ensures stale entries eventually fall out without a cron.
3. **Active binding flag** (per-user cache of currently-valid
customerId). cache miss → fall back to DB lookup. cache hit but
cache.customerId != JWT claim → treat as revoke signal.
4. **Kafka / event-driven revocation** (large clusters, &gt;10 services).
IAM emits `user.tenant.changed` to a topic; subscribers invalidate
local cache on receipt. Strictly over-engineered for a 1-issuer /
5-subscriber fleet.
#### What we picked and why
We picked **option 2** (`revokedCustomerBindings` cache) and combined
it with **token TTL reduction** from 24 h to **1 h**. Net result:
- Worst-case staleness window = `min(token TTL, cache TTL)` = 1 h.
- No per-request IAM call (the user's framing-question was "is the
extra IAM call worth it?" — answer: no, because it moves IAM onto
the hot path and turns it into a single point of failure).
- Single-instance IAM means cache state is authoritative without
sticky sessions or shared cache.
#### What we did NOT do (and the reasons)
- **Drop `customerId` from JWT claim.** Would force every permission
check to call `POST /api/v1/internal/check-permission`, adding
2-5 ms latency per request and making IAM the hot path. Token
claim is now treated as **informational only** — never trusted
for authorization. All decisions go through IAM SAR.
- **Re-sign JWT on every customer change.** Requires Kafka or
webhook infra to broadcast — engineering cost far exceeds risk
reduction given the 1 h TTL already covers the window.
- **Use Authentik's session revocation directly.** Adds an upstream
dependency IAM cannot fail without.
#### Implementation hooks already in place
| File | Role |
| --- | --- |
| `cn.violin.common.context.RequestContext` | exposes `setCustomerIdHint(...)` and `getVerifiedCustomerId()` — verified provenance is now mandatory for SAR / PermissionAspect / RbacController / ResourcePermissionAspect |
| `SubjectAccessReviewRequest.customerVerified` | explicit boolean; SAR denies on `false` |
| `PermissionAspect` | reads `getVerifiedCustomerId()` first, throws `UnauthorizedException` if null |
| `ResourcePermissionAspect` | same |
| `RbacController.access-check` | same |
| `InternalController.check-permission` | accepts only verified (set from HMAC + ServiceAllowlist mapping) |
#### Followup still pending (backlog, not on the critical path)
Add `CustomerBindingRevocationCache` and have `UserService.changeCustomer(...)`
push `(sub, oldCustomerId)` into it. Have `PermissionAspect` consult the cache
as a secondary check (after verified gate). Reduce default `expirationMs`
in `ViolinJwtProperties` from `86_400_000` (24 h) to `3_600_000` (1 h).
These three together close the revocation window to the bound of TTL.
@@ -1,6 +1,7 @@
package cn.violin.iam;
import cn.violin.iam.config.AuthentikProperties;
import cn.violin.iam.config.AuthentikOidcProperties;
import cn.violin.iam.config.OidcProperties;
import cn.violin.iam.config.ServiceAuthProperties;
import cn.violin.iam.config.ViolinJwtProperties;
import org.mybatis.spring.annotation.MapperScan;
@@ -10,11 +11,12 @@ 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")
@SpringBootApplication(scanBasePackages = {"cn.violin"})
@MapperScan("cn.violin.*.mapper")
@EnableConfigurationProperties({
ViolinJwtProperties.class,
AuthentikProperties.class,
OidcProperties.class,
AuthentikOidcProperties.class,
ServiceAuthProperties.class
})
public class ViolinIamApplication extends SpringBootServletInitializer {
@@ -0,0 +1,24 @@
package cn.violin.iam.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
/**
* Configuration bundle for the Authentik-specific OIDC endpoints. All fields
* are optional; when blank, {@link cn.violin.iam.sso.AuthentikConf} falls
* back to the canonical Authentik path
* {@code <issuer>/application/o/<endpoint>/}.
*/
@ConfigurationProperties(prefix = "iam.oidc.authentik")
public record AuthentikOidcProperties(
String tokenEndpoint,
String userinfoEndpoint,
String authorizeEndpoint
) {
@ConstructorBinding
public AuthentikOidcProperties {
tokenEndpoint = tokenEndpoint == null ? "" : tokenEndpoint.strip();
userinfoEndpoint = userinfoEndpoint == null ? "" : userinfoEndpoint.strip();
authorizeEndpoint = authorizeEndpoint == null ? "" : authorizeEndpoint.strip();
}
}
@@ -1,27 +0,0 @@
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,44 @@
package cn.violin.iam.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.ConstructorBinding;
import org.springframework.util.StringUtils;
/**
* Configuration bundle for the OIDC client identity (provider-independent).
*
* <p>Bound to {@code iam.oidc.client.*}; the actual provider endpoints
* (e.g. Authentik-specific paths) live in {@link AuthentikOidcProperties}.</p>
*
* <p>Two fields ({@code issuer}, {@code clientId}) are mandatory at startup
* when the active Spring profile is not {@code local} — local dev may run
* with blank values to defer OIDC wiring to a later iteration.</p>
*/
@ConfigurationProperties(prefix = "iam.oidc.client")
public record OidcProperties(
String issuer,
String clientId,
String clientSecret,
String redirectUri,
String scope,
String profile
) {
@ConstructorBinding
public OidcProperties {
issuer = StringUtils.trimTrailingCharacter(nullSafe(issuer).trim(), '/');
clientId = nullSafe(clientId).trim();
clientSecret = nullSafe(clientSecret).strip();
redirectUri = nullSafe(redirectUri).strip();
scope = StringUtils.hasText(scope) ? scope.strip() : "openid profile email";
profile = StringUtils.hasText(profile) ? profile.trim() : "local";
}
/** True when the active Spring profile equals {@code local} (case-insensitive). */
public boolean isLocal() {
return "local".equalsIgnoreCase(profile);
}
private static String nullSafe(String s) {
return s == null ? "" : s;
}
}
@@ -1,48 +1,113 @@
package cn.violin.iam.sso;
import cn.violin.iam.config.AuthentikProperties;
import lombok.Data;
import cn.violin.iam.config.AuthentikOidcProperties;
import cn.violin.iam.config.OidcProperties;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
@Data
/**
* Resolves OIDC endpoint URLs for the Authentik provider.
*
* <p>Composes two configuration sources:
* <ul>
* <li>{@link OidcProperties} — provider-independent client identity
* (issuer, clientId/secret, redirectUri, scope).</li>
* <li>{@link AuthentikOidcProperties} — Authentik-specific endpoint paths
* (tokenEndpoint, userinfoEndpoint, authorizeEndpoint). Defaults match
* Authentik 2023.x installs; overrides are useful for tenant-specific
* reverse proxies or self-hosted legacy versions.</li>
* </ul>
*
* <p>When {@code iam.oidc.authentik.*} paths are present they take precedence;
* when absent, URLs are derived from {@code iam.oidc.client.issuer} and the
* standard Authentik path {@code /application/o/...}.</p>
*/
@Configuration
@Primary
@EnableConfigurationProperties(AuthentikProperties.class)
@EnableConfigurationProperties({OidcProperties.class, AuthentikOidcProperties.class})
@Slf4j
public class AuthentikConf {
private final AuthentikProperties props;
private final OidcProperties client;
private final AuthentikOidcProperties endpoints;
public AuthentikConf(AuthentikProperties props) {
this.props = props;
public AuthentikConf(OidcProperties client, AuthentikOidcProperties endpoints) {
this.client = client;
this.endpoints = endpoints;
log.info("Authentik OIDC ready: issuer={}, endpoints overridden={}",
client.issuer(),
endpointsOverrideActive());
}
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(); }
/**
* Fail-fast validation: blank {@code issuer} / {@code clientId} are only
* tolerated when the active profile equals {@code local}.
*/
@PostConstruct
void validate() {
if (client.isLocal()) {
if (client.issuer().isBlank() || client.clientId().isBlank()) {
log.warn("iam.oidc.client.issuer/client-id are blank under local profile; "
+ "OIDC login will fail until configured");
}
return;
}
if (client.issuer().isBlank()) {
throw new IllegalStateException(
"iam.oidc.client.issuer must be set for profile=" + client.profile()
+ " (mount K8s Secret or ConfigMap)");
}
if (client.clientId().isBlank()) {
throw new IllegalStateException(
"iam.oidc.client.client-id must be set for profile=" + client.profile());
}
}
public String getIssuer() { return client.issuer(); }
public String getClientId() { return client.clientId(); }
public String getClientSecret() { return client.clientSecret(); }
public String getRedirectUri() { return client.redirectUri(); }
public String getScope() { return client.scope(); }
public String getTokenUrl() {
return base() + "/application/o/token/";
return resolve(endpoints.tokenEndpoint(), "/application/o/token/");
}
public String getUserInfoUrl() {
return base() + "/application/o/userinfo/";
return resolve(endpoints.userinfoEndpoint(), "/application/o/userinfo/");
}
public String getAuthorizeUrl() {
return base() + "/application/o/authorize/";
return resolve(endpoints.authorizeEndpoint(), "/application/o/authorize/");
}
private String base() {
String s = props.getIssuer();
/**
* @return base URL for the Authentik OIDC tenant, with trailing slash trimmed.
* Empty string when {@link OidcProperties#issuer()} is blank.
*/
public String getIssuerBaseUrl() {
String s = client.issuer();
if (s == null) return "";
s = s.trim();
while (s.endsWith("/")) {
s = s.substring(0, s.length() - 1);
return stripTrailingSlash(s.trim());
}
return s;
/**
* @return true when at least one endpoint is overridden via {@code iam.oidc.authentik.*}.
*/
public boolean endpointsOverrideActive() {
return !endpoints.tokenEndpoint().isBlank()
|| !endpoints.userinfoEndpoint().isBlank()
|| !endpoints.authorizeEndpoint().isBlank();
}
private String resolve(String override, String defaultSuffix) {
return override.isBlank() ? getIssuerBaseUrl() + defaultSuffix : override;
}
private static String stripTrailingSlash(String s) {
return s.endsWith("/") ? stripTrailingSlash(s.substring(0, s.length() - 1)) : s;
}
}
+12 -8
View File
@@ -31,20 +31,24 @@ violin:
auth:
exclude-paths: /error
anonymous-message-code: AUTHORIZATION_MISSING
iam:
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}
oidc:
client:
issuer: ${IAM_OIDC_CLIENT_ISSUER:${AUTHENTIK_ISSUER:}}
client-id: ${IAM_OIDC_CLIENT_CLIENT_ID:${AUTHENTIK_CLIENT_ID:}}
client-secret: ${IAM_OIDC_CLIENT_CLIENT_SECRET:${AUTHENTIK_CLIENT_SECRET:}}
redirect-uri: ${IAM_OIDC_CLIENT_REDIRECT_URI:${AUTHENTIK_REDIRECT_URI:}}
scope: ${IAM_OIDC_CLIENT_SCOPE:${AUTHENTIK_SCOPE:openid profile email}}
authentik:
token-endpoint: ${IAM_OIDC_AUTHENTIK_TOKEN_ENDPOINT:}
userinfo-endpoint: ${IAM_OIDC_AUTHENTIK_USERINFO_ENDPOINT:}
authorize-endpoint: ${IAM_OIDC_AUTHENTIK_AUTHORIZE_ENDPOINT:}
management:
endpoints:
@@ -0,0 +1,41 @@
package cn.violin.iam.boot;
import cn.violin.iam.ViolinIamApplication;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestPropertySource;
/**
* Smoke test that boots the full Spring context. Acts as a regression guard
* for the round-5 startup bug: {@link ViolinIamApplication} needed an
* explicit {@code scanBasePackages} for {@code cn.violin.*} so that the
* core module's {@code @Component}-marked beans (notably
* {@code AuthenticationInterceptor} and
* {@code CurrentUserHandlerMethodArgumentResolver}) get registered.
*
* <p>This test exists because round-3 / round-4 unit tests never started
* the context — they only instantiated components in isolation — and so
* the missing scan range remained hidden until a real
* {@code java -cp} / packaged-jar startup was attempted.</p>
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@TestPropertySource(properties = {
"violin.oidc.client.issuer=https://idp.example.com",
"violin.oidc.client.client-id=test-client-id",
"violin.jwt.profile=local",
"violin.iam.profile=local",
// Avoid needing real Postgres for the smoke context.
"spring.datasource.url=jdbc:postgresql://localhost:5432/none",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.datasource.driver-class-name=org.postgresql.Driver",
"spring.flyway.enabled=false"
})
class ApplicationContextLoadsTest {
@Test
void contextLoads() {
// No assertion: success means every wiring resolved, in particular
// WebMvcConfig -> AuthenticationInterceptor.
}
}
@@ -0,0 +1,62 @@
package cn.violin.iam.sso;
import cn.violin.iam.config.AuthentikOidcProperties;
import cn.violin.iam.config.OidcProperties;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies the AuthentikConf URL composition: provider-specific endpoint
* overrides take precedence; blank overrides fall back to issuer + standard
* Authentik path suffix.
*/
class AuthentikConfUrlResolutionTest {
private static OidcProperties client(String issuer, String profile) {
return new OidcProperties(issuer, "client-id-x", "secret", "https://app/cb", "openid", profile);
}
private static AuthentikOidcProperties overrides(String token, String userinfo, String authorize) {
return new AuthentikOidcProperties(token, userinfo, authorize);
}
@Test
void fallsBackToIssuerPlusDefaultPathWhenOverridesAreBlank() {
AuthentikConf conf = new AuthentikConf(
client("https://idp.example.com/", "prod"),
overrides("", "", ""));
assertEquals("https://idp.example.com/application/o/token/", conf.getTokenUrl());
assertEquals("https://idp.example.com/application/o/userinfo/", conf.getUserInfoUrl());
assertEquals("https://idp.example.com/application/o/authorize/", conf.getAuthorizeUrl());
assertFalse(conf.endpointsOverrideActive());
assertEquals("https://idp.example.com", conf.getIssuerBaseUrl());
}
@Test
void explicitEndpointsTakePrecedence() {
AuthentikConf conf = new AuthentikConf(
client("https://idp.example.com/", "prod"),
overrides(
"https://idp.example.com/api/o/token/",
"https://idp.example.com/api/o/userinfo/",
"https://idp.example.com/api/o/authorize/"));
assertEquals("https://idp.example.com/api/o/token/", conf.getTokenUrl());
assertEquals("https://idp.example.com/api/o/userinfo/", conf.getUserInfoUrl());
assertEquals("https://idp.example.com/api/o/authorize/", conf.getAuthorizeUrl());
assertTrue(conf.endpointsOverrideActive());
}
@Test
void partialOverrideKeepsDefaultsForOtherEndpoints() {
AuthentikConf conf = new AuthentikConf(
client("https://idp.example.com", "prod"),
overrides("https://custom.example.com/oauth/token", "", ""));
assertEquals("https://custom.example.com/oauth/token", conf.getTokenUrl());
assertEquals("https://idp.example.com/application/o/userinfo/", conf.getUserInfoUrl());
assertEquals("https://idp.example.com/application/o/authorize/", conf.getAuthorizeUrl());
assertTrue(conf.endpointsOverrideActive());
}
}