Security认证与会话:谁证明身份,谁让登录失效
登录返回302,只能说明这个请求选择了某条响应路径。下一次请求能否恢复身份、旧会话是否失效、同一账号第二次登录会怎样,要沿着凭据验证→会话策略→上下文保存→后续请求加载→注销/过期逐步观察。本页固定Boot4.0.9、Framework7.0.10、Security7.0.8、Java21;旧Security6.3.3的过滤链源码继续由机制课维护,不能拼成一次执行。
可复制工程、启动参数和真实HTTP验证入口见security-browser实验。只使用本机合成身份,不连接企业账号库。浏览器自动携带凭据的风险和CSRF/CORS见浏览器防护;OAuth/OIDC和对象/租户授权由独立单元承接。
链选择与授权规则是两个判断
securityMatcher决定整条SecurityFilterChain是否参与,authorizeHttpRequests().requestMatchers(...)只在已选中的链内决定授权。FilterChainProxy按顺序找第一匹配链,不合并所有匹配规则。@Order(1)的/api/**链应在全匹配兜底链前;只有API链而没有兜底时,其他请求可能完全不经过安全过滤器。
本工程用API链与表单会话兜底链比较同一服务器上的两种行为。API的匿名请求进入明确的401入口;浏览器受保护页面匿名请求走表单登录。permitAll仍经过该链的防护与上下文处理,web.ignoring则绕过安全链,不能为了静态资源速度就把两者视为等价。登录处理URL也必须处于负责表单登录的链匹配范围;如果把链限为/secured/**却仍向/login提交,过滤器不会因配置过formLogin就跨链处理它。
排障先记录请求方法、原始路径/上下文路径、选中链和最终状态,再检查链内过滤器及授权条件。宽泛链先匹配导致意外200时,修复链顺序或matcher,随后同时验证公开、API、登录、管理及未知路径,不能只重测一条成功路径。旧机制课保留宽泛空链遮蔽的确定性反例;新工程用真实Servlet请求验证不同链的实际入口。
Provider验证凭据,编码器管理密码表示
表单过滤器把用户名密码组成未认证Authentication,交AuthenticationManager;常见ProviderManager选择支持该类型的Provider。DaoAuthenticationProvider通过UserDetailsService加载账号/已存密码/权限,再由PasswordEncoder.matches验证输入;它检查账号状态并建立可信认证结果。读取到了用户名、看见一个authenticated=true字段,均不能替代这个过程。
UserDetailsService是读取凭据的边界,可以实现为数据库、LDAP或测试内存;本工程用合成内存源,查询路径不包含真实账号数据。不存在用户与错误密码应给客户端一致的失败语义,同时通过受控服务端事件诊断原因,避免泄露账号枚举信息。登录日志只留合成账号、结果与关联标识;不记录密码、完整Authorization、session id或remember-me值。凭据字段被擦除也不保证其他日志或业务对象从未保存过秘密。
DelegatingPasswordEncoder存储形如{bcrypt}...,前缀决定匹配时使用哪个算法,idForEncode决定新密码的输出。哈希不可当作密文解密,随机salt也意味着同一明文不应期待相同字符串。没有合法id、未知id或参数不受支持时应失败并修复存储/迁移策略,而非给所有旧记录套上{noop}。旧局部实验中的noop仅是固定fixture,本页的正常身份使用成本可调的bcrypt。
一次登录成功不自动升级持久库里的旧哈希:Provider还需UserDetailsPasswordService。匹配成功后判断upgradeEncoding,重新encode并通过该服务写回;没有写回服务,只替换PasswordEncoder Bean不能保证数据库被迁移。真实系统还需处理更新竞争、账号被禁用/改密与事务失败;不能先日志打印“已升级”再忽略写入失败。实验把一个明确标记的{noop}合成遗留记录作为迁移负担:错密不写回、正确登录后变为{bcrypt}且升级计数为1,注销后再次登录仍成功;这是受控迁移对照,正常账号不采用noop,没有运行生产数据库迁移。
固定7.0.8 DaoAuthenticationProvider连续125–144行揭示升级的三个条件:已存编码非空、写回服务不是NOOP、编码器判断需升级;写回发生在返回成功Authentication之前。固定来源。泄漏密码检查器若拒绝,也不会进入正常成功路径。
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
Assert.notNull(authentication.getCredentials(), "Authentication.getCredentials() cannot be null");
String presentedPassword = authentication.getCredentials().toString();
boolean isPasswordCompromised = this.compromisedPasswordChecker != null
&& this.compromisedPasswordChecker.check(presentedPassword).isCompromised();
if (isPasswordCompromised) {
throw new CompromisedPasswordException("The provided password is compromised, please change your password");
}
String existingEncodedPassword = user.getPassword();
boolean upgradeEncoding = existingEncodedPassword != null
&& !Objects.equals(this.userDetailsPasswordService, UserDetailsPasswordService.NOOP)
&& this.passwordEncoder.get().upgradeEncoding(existingEncodedPassword);
if (upgradeEncoding) {
String newPassword = this.passwordEncoder.get().encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
return super.createSuccessAuthentication(principal, authentication, user);
}更换凭据源时,保留认证契约
官方password-storage目录还列出JDBC、UserDetailsManager、CredentialsContainer和缓存等能力。JdbcDaoImpl实现读取,JdbcUserDetailsManager增加管理/密码更新;使用JDBC源时把独立DataSource交给manager,并让同一认证Provider使用该UserDetailsService/PasswordService。默认读取形状如下,固定7.0.8原码117–125行;不是本实验已执行的SQL。固定来源。
public static final String DEF_USERS_BY_USERNAME_QUERY = "select username,password,enabled "
+ "from users "
+ "where username = ?";
// @formatter:on
// @formatter:off
public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY = "select username,authority "
+ "from authorities "
+ "where username = ?";自定义users/authorities查询必须保持映射所需列的顺序/含义,并用参数绑定,不把用户名拼进SQL;权限前缀须与hasRole/hasAuthority契约一致。发行包默认schema不能不核方言就当作MySQL迁移脚本。数据库不可用、查无账号、无授权行是不同原因,均不能降级为可信身份;密码升级写回、禁用与缓存失效需要事务/版本设计。本票运行的是内存源与相同Provider契约;JDBC源的真实库配置和并发写回未运行,不以本机存在MySQL冒领数据库身份源验收。
InMemoryUserDetailsManager也不是生产账号治理方案;复制可擦除凭据、缓存失效与账号撤销责任仍存在。AuthenticationEventPublisher可发布成功/失败事件供审计,但事件到达不是业务提交或可靠审计入库,监听器不应回显凭据;匿名Authentication可能存在且其authenticated标志为true,不能只看这个布尔值判定真实用户。Digest属于官方保留的历史HTTP认证能力,对密码表示有特殊要求,不应把它当bcrypt表单/Basic over TLS的可直接替代;本票保留这个迁移边界,不搭建MD5密码源。LDAP/企业认证归独立扩展,不能通过替换一个Service Bean假定其协议同JDBC。
上下文保存与线程清理分别解决什么
SecurityContextHolder中的Authentication属于当前执行上下文;默认线程策略下,线程池复用使清理尤为重要。SecurityContextRepository决定身份怎样跨请求保存/加载。常见DelegatingSecurityContextRepository组合RequestAttribute与HttpSession:前者支持同一请求的相关分派,后者支持携带会话cookie的后续请求。清空holder不删除HttpSession中的认证;删除cookie也不证明服务端所有会话已撤销。
Security6/7默认以SecurityContextHolderFilter加载并最终清理,它不在响应结束时自动保存任意手工setContext。内置认证过滤器走自己的成功流程和saveContext;如果自定义Controller登录,必须明确调用认证管理器、SessionAuthenticationStrategy、创建独立SecurityContext及repository.saveContext,且在响应提交前完成必要保存。少做一项可能出现“本请求Principal正确,下一请求匿名”、未轮换session id或未计入并发上限。
观察时把四项分开:登录响应、带cookie再次GET的身份、没有cookie的独立客户端、注销后再次GET。最后一种能证明本会话撤销;独立客户端匿名能反驳明显的跨请求身份污染,但不能单凭一次HTTP请求证明所有工作线程和异常路径都完成清理。线程级finally路径仍需固定源码及旧局部实验的异常/holder断言共同支撑。
表单登录与注销是一组状态转换
默认表单入口使用用户名/密码字段和POST认证,成功处理器可能按保存的请求重定向,失败处理器可能跳回登录页。工程测试显式断言成功302的Location和错密401/并发上限409及失败标记,避免客户端自动跟随302后只看到最终200;这些状态是本工程配置。认证失败应不产生可用身份;合法登录后,SessionAuthenticationStrategy先处理会话约束,成功流程再保存上下文。只有READ/USER权限的身份访问管理路径仍应被拒绝。
启用CSRF时,登录POST也需要令牌;攻击者可能诱导受害者登录攻击者账号,随后让受害者把敏感信息写入该账号。permitAll允许匿名访问不等于忽略CSRF。登录成功会清除旧CSRF token,接下来的状态修改应重新取得当前令牌,不能复用预登录表单中的旧值。
注销也不是普通GET超链接。默认CSRF保护下,GET /logout通常是确认页,实际状态修改走携带令牌的POST。LogoutHandler依配置失效当前session、清SecurityContext/repository并通知remember-me服务,成功处理器决定返回/跳转。自定义/logout Controller若只删一个浏览器cookie,会留下服务端状态或remember-me自动重建登录的机会。需要“所有设备下线”时必须定位并撤销所有相应会话/令牌,而不是扩大一次本机logout的证明范围。
会话固定:登录前后必须换一个可用标识
会话固定攻击的前提是攻击者知道或控制受害者认证前的session id;如果认证后仍用它表示身份,攻击者可能复用这个标识。Servlet3.1+默认changeSessionId策略在认证时更换ID,保留同一会话的属性。newSession新建会话且不保留普通属性,migrateSession则复制属性;它们影响购物车、预登录状态和监听器,不是三个拼写不同的开关。
正确负例先访问一个确实创建session的公开端点,再登录,验证认证前后id不同;随后独立请求带旧id访问受保护资源应失败,带新id成功。若一开始根本没有session,则“登录后出现cookie”不能验证防固定。也不能只观察Set-Cookie而不测试旧标识,因为代理/cookie路径配置错误可能让客户端继续发送别的标识。
换ID不抹去恶意的会话属性,不解决XSS、设备被控、已盗取的新cookie或并发请求竞态。跨站部署还需Secure/HttpOnly/SameSite、限定Path/Domain和TLS;这些约束分别减少传输/脚本/跨站暴露,不能互相替代。对URL重写的jsessionid也应检查容器与firewall策略,避免在URL、Referer和日志中传播会话秘密。
并发会话:登记、过期与下一次请求
maximumSessions(1)表达每个主体的会话上限。默认达到上限可让旧会话被标记expired,旧客户端在后续请求由ConcurrentSessionFilter处理失效;它不是抢占式终止已经执行中的业务。设置maxSessionsPreventsLogin(true)则拒绝第二次登录,需要给客户端清楚的失败反馈,并在过期/注销时正确释放登记。
SessionRegistry记录主体与会话;HttpSessionEventPublisher把容器销毁/ID变化事件带入Spring生命周期,避免真实session已销毁而登记长期残留。自定义UserDetails必须有稳定equals/hashCode,否则同一账号可能被登记为不同主体。自定义认证成功流程也须执行策略与注册,不可绕过上限。
本实验是单JVM内存登记,默认实验配置拒绝新登录:第一个cookie客户端登录后仍可用,第二个收到明确409且不保存身份;第一个注销或其真实容器会话到期后,第二个再次登录成功。另启独立lab.prevent-new-login=false进程验证淘汰模式:第二次登录成功,旧会话下一次请求由ConcurrentSessionFilter处理为401并标记expired,再访问为匿名302;新会话仍可用。多副本若各有独立SessionRegistry,上限可能变成“每节点1个”;粘性路由也无法承诺全局一致。集群会话存储和Session适配由专门单元讲解,需要共享登记、故障/过期传播与业务重鉴权策略,不能只共享cookie名字。
固定7.0.8 ConcurrentSessionControlAuthenticationStrategy连续148–162行:配置拒绝时立即抛SessionAuthenticationException;另一分支按最近请求排序,只调用expireNow标记。它没有取消业务线程,也没有在此直接销毁Servlet会话。固定来源。两个模式分进程实跑,必须区分策略标记时点与后续请求实际失效时点。
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
SessionRegistry registry) throws SessionAuthenticationException {
if (this.exceptionIfMaximumExceeded || (sessions == null)) {
throw new SessionAuthenticationException(
this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
}
// Determine least recently used sessions, and mark them for invalidation
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
for (SessionInformation session : sessionsToBeExpired) {
session.expireNow();
}
}remember-me:登录便利性与撤销窗口
关闭浏览器会话cookie后仍可自动登录,通常来自remember-me而非holder没有清理。签名型token包含用户名、到期时间、算法及由密码表示和服务端key参与的签名,服务端重新加载用户并验证;它不携带明文密码,但被复制后的有效token具有凭据作用。配置key应来自受控秘密,不能把教材固定字符串当生产密钥;实验只在本次进程中使用合成值。
TokenBasedRememberMeServices适合说明无持久token表的方案:修改密码表示/改变key会影响后续自动登录验证,但当前已建立的session不一定随之撤销。退出登录清浏览器token也不能让攻击者事先保存的同一签名token在服务端凭空消失;若要逐设备即时撤销,应维护可查询的版本/撤销状态或采用具有服务端记录的策略。
PersistentTokenBasedRememberMeServices维护series/token/last_used,自动登录时核对并轮换token;已知series对应token不符时删除该用户全部persistent记录并抛CookieTheftException。真实注销有Authentication时也删除该用户全部series,不仅是当前设备的remember-me记录;其他已认证session不会随之自动失效。它增加存储、并发请求/轮换竞争与清理责任,不是签名cookie的同义词。本票实际选择此实现,但token repository为InMemoryTokenRepositoryImpl:能验证series/token轮换和服务端记录删除,进程重启即丢失,不能证明数据库耐久性或全部盗用检测并发组合。签名型方案按固定文档/源码对照,不冒称两种实现都已运行。
remember-me Authentication与充分认证有区别,敏感操作可要求fullyAuthenticated()重新输入凭据。实验清除当前JSESSIONID后只携remember-me恢复普通身份,再请求要求充分认证的入口,观察提升认证;logout后自动登录cookie清除,重试受保护端点失败。服务端repository删除记录后,复制的当前token也不能重新自动登录;同账号另一条已经建立的交互session仍可能有效,注销本会话不等于全设备即时注销。注销后重放走series不存在分支,不是仍有series时的旧token盗用分支;后者异常传播位置不同,未实跑时不能承诺同样302。到期、改密与盗用影响按对应实现的真实测试及固定契约分别报告。
固定7.0.8 PersistentTokenBasedRememberMeServices连续104–130行先查series;token不符删除该用户token,过期则拒绝,匹配时同series生成新token并写库后发cookie。固定来源。这一段不依赖当前密码哈希来验证token,所以改密/升级哈希不自动撤销persistent记录。记忆登录先轮换token、随后才进入认证/会话策略,丢JSESSIONID也不会释放旧SessionRegistry登记;并发上限与自动登录必须组合验证。
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
// No series match, so we can't authenticate using this cookie
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}
// We have a match for this user/series combination
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
// Token also matches, so login is valid. Update the token value, keeping the
// *same* series number.
this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'",
token.getUsername(), token.getSeries()));
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
addCookie(newToken, request, response);
}失败响应、异常覆盖与安全观测
错误密码由认证过滤器自己的失败路径处理;下游授权失败通常由ExceptionTranslationFilter翻译,匿名或remember-me身份可能需要认证入口,充分认证但缺权限交AccessDeniedHandler。API可以配置401/403,表单可以302,不应把所有失败硬写成同一HTTP状态码。已经提交的响应不能再可靠改成统一JSON,ControllerAdvice也不包住所有安全过滤器。
StrictHttpFirewall与Servlet容器可能在Controller之前拒绝路径;CSRF拒绝也可能发生在认证/业务之前。诊断依次记录选中链、认证阶段、授权判定、响应是否提交,且用受控事件而非token原文关联。实际身份源失效时应拒绝未能验证的凭据,给出可重试/恢复策略,不能将验证异常当匿名直接放行。
固定源码、旧异常后holder为空的实验,以及新真实HTTP的错误凭据/缺权限/匿名隔离是不同层证据。它们没有覆盖生产TLS、全部线程调度、外部SSO、真实用户数据或漏洞扫描。
条件变化:会话状态、撤销与恢复成本
设合成业务峰值1000请求/秒、活跃会话200000个、每会话序列化后2KiB,单份状态约390.6MiB,复制两份约781.3MiB,尚未计索引、协议和内存对象开销。身份源校验平均20ms、每秒新增登录50次,平均约1个并行校验;在每请求都远程查身份的方案中则约20个。如果恶化到200ms,分别约10与200。这里是固定单位模型,不是本机吞吐或内存实测。
选择A:服务端session、短会话过期、集中撤销。请求路径避免每次重做密码哈希,但要承担会话存储/副本/过期与故障恢复;共享存储不可用时不能默默接受无法校验的身份。选择B:每请求认证或本地验证短时凭据,减少session状态,但带来凭据传输、密码校验/签名成本、撤销延迟和权限变更的额外策略。不要让Basic在无TLS链路传秘密,也不要将浏览器会自动携带的凭据称为天然免CSRF。
若remember-me有效期14天、业务要求离职10秒内撤销,单纯签名cookie过期不满足目标;要增加权威撤销/版本检查、服务端token记录或缩短凭据并定义刷新策略。每次检查增加可用性/时延依赖,缓存10秒又留下明确失效窗口。验收必须注入改密、注销、身份源不可用和多节点失联,观察是否守住“已撤销者不再发起新的敏感副作用”;已提交操作不能由清cookie倒转。
学习路径是先把单节点真实会话闭环验证清楚,再为集群和OAuth扩展状态模型。具体工程命令、输入、响应与清理见实验README和U29证据;本页不把单节点通过升级成生产容量承诺。
完整配置与公开复现入口
从仓库根执行以下入口,runner自行选随机loopback端口,创建独立cookie客户端并在finally停止自有进程。返回码0表示该次断言全部成立,非零应读取它输出的独有日志目录;不要把只完成Maven打包当认证实验通过。
JAVA_HOME=$(/usr/libexec/java_home -v21) python3 labs/security-browser/run.py依赖固定在实验POM,环境和HTTP输入/响应摘要见U29运行证据。默认会话过期5分钟;并发实验为单主体1个会话且拒绝新登录,remember-user为演示自动登录后既有会话仍存在而预留2个名额;这都是合成实验显式配置。独立短寿命场景把调用者自己的会话闲置上限改为1秒、remember-me有效期改为2秒,再验证到期与恢复,不改本机服务配置。
实际完整安全配置如下。API使用NullSecurityContextRepository和STATELESS,CSRF用单独cookie而非认证session;浏览器链保留默认会话CSRF仓库;两条链均启用明确CORS来源和CSRF。Basic凭据传输只在本机合成测试,真实部署须用TLS。
package study.securitybrowser;
import java.util.UUID;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.security.web.authentication.rememberme.InMemoryTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.context.NullSecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.header.writers.StaticHeadersWriter;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class SecurityConfig {
private static final String CSP = "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; "
+ "form-action 'self'; style-src 'self' 'unsafe-inline'";
@Bean
WebSecurityCustomizer firewall() {
return web -> web.httpFirewall(new StrictHttpFirewall())
.requestRejectedHandler((request, response, failure) -> {
// 仅标明真实 firewall 的拒绝位置;不回显恶意 URL 或异常内容。
response.setHeader("X-Lab-Rejected-By", "StrictHttpFirewall");
response.setStatus(400);
});
}
@Bean
CorsConfigurationSource corsPolicy() {
var policy = new CorsConfiguration();
policy.setAllowedOrigins(List.of("https://classroom.example"));
policy.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
policy.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN", "X-CSRF-TOKEN"));
policy.setAllowCredentials(true);
policy.setMaxAge(600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", policy);
return source;
}
@Bean
PersistentTokenRepository rememberTokens() {
return new InMemoryTokenRepositoryImpl();
}
@Bean
SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
HttpSessionEventPublisher sessionEvents() {
return new HttpSessionEventPublisher();
}
@Bean
PasswordEncoder passwords() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
SyntheticCredentials credentials(PasswordEncoder passwords) {
return new SyntheticCredentials(passwords);
}
@Bean
DaoAuthenticationProvider provider(SyntheticCredentials credentials, PasswordEncoder passwords) {
var provider = new DaoAuthenticationProvider(credentials);
provider.setPasswordEncoder(passwords);
provider.setUserDetailsPasswordService(credentials);
return provider;
}
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http, DaoAuthenticationProvider provider, CorsConfigurationSource corsPolicy) throws Exception {
var csrfCookies = new CookieCsrfTokenRepository();
csrfCookies.setCookiePath("/api");
return http.securityMatcher("/api/**")
.authenticationProvider(provider)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/open", "/api/csrf").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.securityContext(context -> context.securityContextRepository(new NullSecurityContextRepository()))
.requestCache(cache -> cache.requestCache(new NullRequestCache()))
.csrf(csrf -> csrf.csrfTokenRepository(csrfCookies))
.cors(cors -> cors.configurationSource(corsPolicy))
.httpBasic(basic -> basic.authenticationEntryPoint((request, response, failure) -> {
response.setHeader("WWW-Authenticate", "Basic realm=\"u29-api\"");
response.setStatus(401);
}))
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, failure) -> {
response.setHeader("WWW-Authenticate", "Basic realm=\"u29-api\"");
response.setStatus(401);
})
.accessDeniedHandler((request, response, failure) -> response.setStatus(403)))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(CSP))
.addHeaderWriter(new StaticHeadersWriter("X-Lab-Chain", "api")))
.build();
}
@Bean
@Order(2)
SecurityFilterChain browser(HttpSecurity http, DaoAuthenticationProvider provider, SessionRegistry registry,
SyntheticCredentials credentials, PersistentTokenRepository tokens, CorsConfigurationSource corsPolicy,
@Value("${lab.remember-seconds:300}") int rememberSeconds,
@Value("${lab.prevent-new-login:true}") boolean preventNewLogin) throws Exception {
// 没有 securityMatcher:最后一条链覆盖全部剩余请求,避免新端点落到安全链之外。
return http.authenticationProvider(provider)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/browser/prelogin", "/browser/csrf", "/browser/credentials/legacy").permitAll()
.requestMatchers("/browser/sensitive").fullyAuthenticated()
.requestMatchers("/browser/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form.defaultSuccessUrl("/browser/whoami", true)
.failureHandler((request, response, failure) -> {
boolean capped = failure instanceof SessionAuthenticationException;
response.setStatus(capped ? 409 : 401);
response.setHeader("X-Lab-Login-Failure", capped ? "session-limit" : "bad-credentials");
}))
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.changeSessionId())
.sessionConcurrency(concurrency -> concurrency
// 丢 cookie 不等于服务端会话消失:remember-me 演示账号预留第二个名额。
.maximumSessions(authentication -> authentication.getName().equals("remember-user") ? 2 : 1)
.maxSessionsPreventsLogin(preventNewLogin).sessionRegistry(registry)
.expiredSessionStrategy(event -> {
event.getResponse().setStatus(401);
event.getResponse().setHeader("X-Lab-Session-State", "expired");
})))
.logout(logout -> logout.deleteCookies("JSESSIONID"))
.cors(cors -> cors.configurationSource(corsPolicy))
.rememberMe(remember -> remember
.key(UUID.randomUUID().toString())
.userDetailsService(credentials).tokenRepository(tokens).tokenValiditySeconds(rememberSeconds))
.exceptionHandling(errors -> errors.accessDeniedHandler((request, response, failure) -> response.setStatus(403)))
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(CSP))
.addHeaderWriter(new StaticHeadersWriter("X-Lab-Chain", "browser")))
.build();
}
}凭据源的一个容易漏掉的细节:返回UserDetails副本。ProviderManager默认擦除认证结果凭据,若直接返回与内存源共享的可擦除User对象,第一次登录可能把源里的密码也清掉,第二次失败。这与密码哈希错误是两类故障。
package study.securitybrowser;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UserDetailsPasswordService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
/** 仅进程内的合成凭据源;不是数据库、生产账号或可复用的默认生产密码。 */
public class SyntheticCredentials implements UserDetailsService, UserDetailsPasswordService {
private final Map<String, UserDetails> users = new ConcurrentHashMap<>();
private final Map<String, Integer> upgrades = new ConcurrentHashMap<>();
public SyntheticCredentials(PasswordEncoder encoder) {
for (String name : new String[] {"reader", "operator", "session-user", "remember-user"}) {
users.put(name, User.withUsername(name).password(encoder.encode("lab-password"))
.roles(name.equals("operator") ? "ADMIN" : "USER").build());
}
users.put("legacy", User.withUsername("legacy").password("{noop}lab-password").roles("USER").build());
}
@Override
public UserDetails loadUserByUsername(String username) {
UserDetails user = users.get(username);
if (user == null) {
throw new UsernameNotFoundException("Synthetic user not found");
}
// ProviderManager 默认擦除认证结果的凭据;不能让结果与凭据源共享可擦除的 User 实例。
return User.withUserDetails(user).build();
}
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
UserDetails replacement = User.withUserDetails(user).password(newPassword).build();
users.put(user.getUsername(), replacement);
upgrades.merge(user.getUsername(), 1, Integer::sum);
return User.withUserDetails(replacement).build();
}
public Map<String, Object> legacyMetadata() {
String stored = users.get("legacy").getPassword();
return Map.of("encoding", stored.substring(1, stored.indexOf('}')),
"upgrades", upgrades.getOrDefault("legacy", 0));
}
}闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。