From 8b6d18f45eb236833f1b7efbfd1219a008a17598 Mon Sep 17 00:00:00 2001 From: panxiao81 Date: Sun, 27 Sep 2026 17:56:26 +0000 Subject: [PATCH] =?UTF-8?q?=E7=94=A8=20Spring=20Security=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9B=BF=E4=BB=A3=E7=99=BB=E5=BD=95=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E6=80=A7=20Filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ad-login.md | 6 ++- docs/native-validation.md | 3 ++ scripts/native-smoke.py | 2 +- .../web/SignInAvailabilityFilter.java | 35 ------------- .../interfaces/web/SignInController.java | 2 + .../configuration/SecurityConfiguration.java | 25 ++++++--- .../iam/login/IamLoginApplicationTests.java | 4 +- .../web/SignInIntegrationTests.java | 51 +++++++++++-------- 8 files changed, 62 insertions(+), 66 deletions(-) delete mode 100644 src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInAvailabilityFilter.java diff --git a/docs/ad-login.md b/docs/ad-login.md index 21e1327..631cfe0 100644 --- a/docs/ad-login.md +++ b/docs/ad-login.md @@ -29,6 +29,9 @@ try-with-resources 管理已认证用户仓储会话;基础设施的 `AdUserRe ## 启用 +`iam.ad.enabled=true` 时才装配登录 Controller 与浏览器安全链(含 formLogin)。 +未启用时入口由兜底安全链拒绝,匿名 GET 返回 401;不注册密码处理端点。 + 正常 JVM 构建: ```sh @@ -77,7 +80,8 @@ LDAP endpoint identification 或用信任所有证书的 socket factory。连接 AD 根范围查询可能返回 DomainDnsZones/ForestDnsZones 等分区 referral;配置为 ignore, 由仓储的 LdapTemplate 忽略 partial result,不使用 throw 打断用户查询,也不 follow 转发用户凭据。 -浏览器入口只接受 HTTPS;明文请求返回 426。默认不信任转发头。若以后由代理终结 TLS, +浏览器安全链通过 Spring Security `redirectToHttps` 将明文请求重定向至 HTTPS, +重定向前不执行密码验证或退出。默认不信任转发头。若以后由代理终结 TLS, 必须配合仅受信代理可达的后端网络和转发头配置,不能公开一个信任任意 forwarded header 的 HTTP 端口。当前开发验收由应用直接终结 TLS。 diff --git a/docs/native-validation.md b/docs/native-validation.md index a39c230..f65a049 100644 --- a/docs/native-validation.md +++ b/docs/native-validation.md @@ -26,6 +26,9 @@ WebAuthn 集成;TOTP、恢复方式与已有 Authelia MFA 的迁移方式需 ## Native 与监控 - CI 构建 Native 产物并对该产物执行集成测试;测试报告区分 JVM 与 Native。 +- 登录 Controller 与安全链使用 `@ConditionalOnProperty(iam.ad.enabled)`;AOT 在构建时 + 决定 bean 是否存在。AD Native 产物必须在 AOT 阶段启用此属性,验证实际入口与兜底链, + 不能假设运行时修改属性会重新装配 bean。本轮只验证 JVM,尚未验收该 Native 路径。 - 最终运行镜像无需 JRE,不允许以回退 JVM 的方式令 Native 验收通过。 - LDAP、MFA、数据库、TLS、JSON 和 Hydra HTTP 客户端全部在 Native 中执行。 - 纳入 Actuator、Micrometer Prometheus 与 OpenTelemetry/分布式追踪;实际发起请求后 diff --git a/scripts/native-smoke.py b/scripts/native-smoke.py index c51c2ad..25d48c1 100755 --- a/scripts/native-smoke.py +++ b/scripts/native-smoke.py @@ -80,7 +80,7 @@ def main(): ready_seconds = time.monotonic() - started assert request('/actuator/prometheus')[0] == 401, 'Anonymous metrics must be rejected' assert request('/')[0] == 401, 'Anonymous application access must be rejected' - assert request('/signin')[0] == 404, 'Sign-in must be disabled without directory configuration' + assert request('/signin')[0] == 401, 'Sign-in must be disabled without directory configuration' status, before = request('/actuator/prometheus', authenticated=True) assert status == 200, 'Authenticated Prometheus scrape failed' for _ in range(3): diff --git a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInAvailabilityFilter.java b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInAvailabilityFilter.java deleted file mode 100644 index 9dd8b64..0000000 --- a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInAvailabilityFilter.java +++ /dev/null @@ -1,35 +0,0 @@ -package top.ddupan.iam.login.authentication.interfaces.web; - -import java.io.IOException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.web.filter.OncePerRequestFilter; - -/** Rejects disabled or plaintext sign-in before any credentials reach authentication. */ -public final class SignInAvailabilityFilter extends OncePerRequestFilter { - private final boolean enabled; - - public SignInAvailabilityFilter(boolean enabled) { - this.enabled = enabled; - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getServletPath(); - if (path.isEmpty()) path = request.getRequestURI().substring(request.getContextPath().length()); - return !path.equals("/signin") && !path.startsWith("/signin/"); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain chain) throws ServletException, IOException { - if (!enabled || !request.isSecure()) { - response.setHeader("Cache-Control", "no-store"); - response.setStatus(enabled ? 426 : 404); - return; - } - chain.doFilter(request, response); - } -} diff --git a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java index 5a8b411..4f889ad 100644 --- a/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java +++ b/src/main/java/top/ddupan/iam/login/authentication/interfaces/web/SignInController.java @@ -1,6 +1,7 @@ package top.ddupan.iam.login.authentication.interfaces.web; import java.util.Map; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -13,6 +14,7 @@ import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryPrin /** Renders Spring Security's login pages; form processing belongs to the security filters. */ @RestController +@ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true") public class SignInController { private final PageRenderer renderer; diff --git a/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java b/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java index 48d4241..02a543d 100644 --- a/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java +++ b/src/main/java/top/ddupan/iam/login/configuration/SecurityConfiguration.java @@ -1,6 +1,7 @@ package top.ddupan.iam.login.configuration; import java.time.Duration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; @@ -14,12 +15,9 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; -import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import top.ddupan.iam.login.authentication.application.VerifyPassword; -import top.ddupan.iam.login.authentication.infrastructure.ad.AdProperties; import top.ddupan.iam.login.authentication.infrastructure.security.DirectoryAuthenticationProvider; -import top.ddupan.iam.login.authentication.interfaces.web.SignInAvailabilityFilter; @Configuration(proxyBeanMethods = false) @EnableMultiFactorAuthentication(authorities = {}) @@ -42,13 +40,14 @@ class SecurityConfiguration { @Bean @Order(2) - SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords, AdProperties ad) throws Exception { + @ConditionalOnProperty(prefix = "iam.ad", name = "enabled", havingValue = "true") + SecurityFilterChain browser(HttpSecurity http, VerifyPassword passwords) throws Exception { var passwordFactor = AuthorizationManagerFactories.multiFactor() .requireFactor(factor -> factor.passwordAuthority().validDuration(Duration.ofMinutes(10))) .build(); - return http + return http.securityMatcher("/signin", "/signin/**", "/assets/**") + .redirectToHttps(Customizer.withDefaults()) .authenticationManager(new ProviderManager(new DirectoryAuthenticationProvider(passwords))) - .addFilterBefore(new SignInAvailabilityFilter(ad.enabled()), CsrfFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers("/error", "/signin", "/signin/password", "/assets/**").permitAll() .requestMatchers("/signin/mfa").access(passwordFactor.authenticated()) @@ -68,4 +67,18 @@ class SecurityConfiguration { + "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"))) .build(); } + + @Bean + @Order(3) + SecurityFilterChain fallback(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests(auth -> auth + .requestMatchers("/error").permitAll() + .anyRequest().denyAll()) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) + .requestCache(cache -> cache.disable()) + .logout(logout -> logout.disable()) + .build(); + } + } diff --git a/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java b/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java index fa16d00..ddac059 100644 --- a/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java +++ b/src/test/java/top/ddupan/iam/login/IamLoginApplicationTests.java @@ -46,10 +46,10 @@ class IamLoginApplicationTests { @Test void signInIsDisabledUntilDirectoryIsConfigured() throws Exception { - mvc.perform(get("/signin").secure(true)).andExpect(status().isNotFound()); + mvc.perform(get("/signin").secure(true)).andExpect(status().isUnauthorized()); mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/signin/password") .secure(true).with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf())) - .andExpect(status().isNotFound()); + .andExpect(status().isUnauthorized()); } @Test diff --git a/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java b/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java index 7798312..259608f 100644 --- a/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java +++ b/src/test/java/top/ddupan/iam/login/authentication/interfaces/web/SignInIntegrationTests.java @@ -54,22 +54,22 @@ class SignInIntegrationTests { @Test void browserRequiresHttpsCsrfAndOrderedSteps() throws Exception { - mvc.perform(get("/signin")).andExpect(status().isUpgradeRequired()); - mvc.perform(get("/signin/mfa").secure(true)).andExpect(redirectedUrl("/signin")); - mvc.perform(post("/signin/password").secure(true).param("username", "alice") + mvc.perform(get("/signin")).andExpect(redirectedUrl("https://localhost/signin")); + mvc.perform(get("/signin/mfa").with(https())).andExpect(redirectedUrl("/signin")); + mvc.perform(post("/signin/password").with(https()).param("username", "alice") .param("password", "fixture-password")).andExpect(status().isForbidden()); } @Test void successfulPasswordRotatesSessionAndStopsBeforeMfa() throws Exception { var session = new MockHttpSession(); - mvc.perform(get("/signin").secure(true).session(session)).andExpect(status().isOk()); + mvc.perform(get("/signin").with(https()).session(session)).andExpect(status().isOk()); String oldId = session.getId(); - mvc.perform(post("/signin/password").secure(true).session(session).with(csrf()) + mvc.perform(post("/signin/password").with(https()).session(session).with(csrf()) .param("username", "alice").param("password", "fixture-password")) .andExpect(redirectedUrl("/signin/mfa")); assertThat(session.getId()).isNotEqualTo(oldId); - var html = mvc.perform(get("/signin/mfa").secure(true).session(session)) + var html = mvc.perform(get("/signin/mfa").with(https()).session(session)) .andExpect(status().isOk()).andExpect(header().string("Cache-Control", "no-store")) .andReturn().getResponse().getContentAsString(); assertThat(html).contains("mfa-pending", "gitea-admins", "\\u003c/script\\u003e") @@ -78,7 +78,7 @@ class SignInIntegrationTests { assertThat(authentication.isAuthenticated()).isTrue(); assertThat(authentication.getCredentials()).isNull(); assertThat(authentication.getAuthorities()).extracting("authority").containsExactly("FACTOR_PASSWORD"); - mvc.perform(get("/").secure(true).session(session).accept(MediaType.APPLICATION_JSON)) + mvc.perform(get("/").with(https()).session(session).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isForbidden()); } @@ -86,14 +86,14 @@ class SignInIntegrationTests { @Test void failedPasswordDoesNotRetainIdentityAndRestartInvalidatesSession() throws Exception { var session = new MockHttpSession(); - mvc.perform(get("/signin").secure(true).session(session)); - mvc.perform(post("/signin/password").secure(true).session(session).with(csrf()) + mvc.perform(get("/signin").with(https()).session(session)); + mvc.perform(post("/signin/password").with(https()).session(session).with(csrf()) .param("username", "alice").param("password", "wrong")) .andExpect(redirectedUrl("/signin?error")); assertThat(session.getAttribute("SPRING_SECURITY_CONTEXT")).isNull(); - mvc.perform(get("/signin").secure(true).param("error", "").session(session)) + mvc.perform(get("/signin").with(https()).param("error", "").session(session)) .andExpect(content().string(org.hamcrest.Matchers.containsString("无法验证账号"))); - mvc.perform(post("/signin/restart").secure(true).session(session).with(csrf())) + mvc.perform(post("/signin/restart").with(https()).session(session).with(csrf())) .andExpect(redirectedUrl("/signin")); assertThat(session.isInvalid()).isTrue(); } @@ -101,7 +101,7 @@ class SignInIntegrationTests { void plaintextPasswordPostCannotReachDirectory() throws Exception { int before = Directory.INSTANCE.binds.get(); mvc.perform(post("/signin/password").with(csrf()).param("username", "alice") - .param("password", "fixture-password")).andExpect(status().isUpgradeRequired()); + .param("password", "fixture-password")).andExpect(redirectedUrl("https://localhost/signin/password")); assertThat(Directory.INSTANCE.binds.get()).isEqualTo(before); } @@ -113,41 +113,50 @@ class SignInIntegrationTests { context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(previous.getPrincipal(), null, List.of(FactorGrantedAuthority.withAuthority(FactorGrantedAuthority.PASSWORD_AUTHORITY) .issuedAt(Instant.now().minusSeconds(601)).build()))); - mvc.perform(get("/signin/mfa").secure(true).session(session)) + mvc.perform(get("/signin/mfa").with(https()).session(session)) .andExpect(status().is3xxRedirection()); } @Test void directorySessionCannotScrapeManagementAndLogoutClearsAuthentication() throws Exception { var session = login(); - mvc.perform(get("/actuator/prometheus").secure(true).session(session)) + mvc.perform(get("/actuator/prometheus").with(https()).session(session)) .andExpect(status().isUnauthorized()); mvc.perform(post("/signin/restart").session(session).with(csrf())) - .andExpect(status().isUpgradeRequired()); + .andExpect(redirectedUrl("https://localhost/signin/restart")); assertThat(session.isInvalid()).isFalse(); - mvc.perform(post("/signin/restart").secure(true).session(session)) + mvc.perform(post("/signin/restart").with(https()).session(session)) .andExpect(status().isForbidden()); - mvc.perform(post("/signin/restart").secure(true).session(session).with(csrf())) + mvc.perform(post("/signin/restart").with(https()).session(session).with(csrf())) .andExpect(redirectedUrl("/signin")); assertThat(session.isInvalid()).isTrue(); - mvc.perform(get("/signin/mfa").secure(true)).andExpect(status().is3xxRedirection()); + mvc.perform(get("/signin/mfa").with(https())).andExpect(status().is3xxRedirection()); } @Test void managementBasicCredentialsCannotAuthenticateBrowserLogin() throws Exception { - mvc.perform(get("/actuator/prometheus").secure(true).with( + mvc.perform(get("/actuator/prometheus").with(https()).with( org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors .httpBasic("fixture-monitor", "fixture-monitor-password"))) .andExpect(status().isOk()); - mvc.perform(get("/signin/mfa").secure(true).with( + mvc.perform(get("/signin/mfa").with(https()).with( org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors .httpBasic("fixture-monitor", "fixture-monitor-password"))) .andExpect(status().is3xxRedirection()); } + private static org.springframework.test.web.servlet.request.RequestPostProcessor https() { + return request -> { + request.setScheme("https"); + request.setSecure(true); + request.setServerPort(443); + return request; + }; + } + private MockHttpSession login() throws Exception { var session = new MockHttpSession(); - mvc.perform(post("/signin/password").secure(true).session(session).with(csrf()) + mvc.perform(post("/signin/password").with(https()).session(session).with(csrf()) .param("username", "alice").param("password", "fixture-password")) .andExpect(redirectedUrl("/signin/mfa")); return session;