认证和授权:基础原理与 Spring Security 密码认证¶
协议学习见 OAuth2 与 OIDC,令牌格式见 JWT 与 JWKS,入口治理见 网关、过滤器与拦截器。各主题只在对应文章维护完整解释。
1. 先区分注册、认证和授权¶
- 注册:创建账号,保存必要资料和密码哈希;注册成功是否立即登录属于产品设计,不是框架自动保证。
- 认证(Authentication):核验凭据,回答“你是谁”。凭据(Credentials)是证明身份的材料,如密码或令牌,用户自己提交的 ID 不是证明。
- 授权(Authorization):在身份可信后,判断“你能做什么”。
例如:登录证明你是 alice;是否能删除某份文件,还要判断对应权限与文件所有权。只要求 authenticated() 表示必须已有认证身份,不会自动检查对象是否属于你。
用户是登录的人;客户端是发起请求的应用,可以是网站、App 或后端程序。第一方客户端由服务提供方自己控制,第三方客户端由其他主体控制。“第一方”不是可以省略凭据验证的理由。协议中的客户端标识与认证方式见 OAuth2 角色。
权限、角色和 RBAC¶
权限(Authority)是安全框架用于授权判断的能力标识,如 book:read;角色(Role)是业务身份分组,如管理员。RBAC 是 Role-Based Access Control(基于角色的访问控制),把用户关联到角色,再按角色授予能力。
角色名称如何映射为权限字符串,由应用与框架配置决定。Spring Security 的 hasAuthority 匹配指定权限;hasRole 通常使用角色前缀约定,不能忽略配置直接混用。角色授权仍不等于拥有所有业务对象;scope、role 与 audience 的区别在协议文章统一说明。
2. 为什么数据库不保存原始密码¶
哈希把输入转换为摘要,不是以后拿密钥解密还原。用于密码存储时,还需要随机盐和可调整的计算成本,增加数据库泄露后的批量猜测成本。
BCrypt 的结果字符串包含算法参数、随机盐和计算结果。注册时调用 encode 保存哈希,认证时调用 matches(输入密码, 已存哈希)。随机盐使同一密码的两次编码结果通常不同,因此不能通过两次哈希字符串相等判断密码;使用 BCrypt 时不需要自己再拼接一个盐字段。
密码哈希本身仍是敏感数据,不应返回前端或写入日志。MD5、SHA-256 等快速通用摘要不适合单独作为密码存储方案。密码编码器及计算成本应结合框架支持和实际环境选择;后面的 BCrypt 实验是理解调用链,不是对所有新系统固定选型。
HTTPS(HTTP over TLS,使用传输层安全保护 HTTP)保护传输,密码哈希保护数据库中的密码,令牌签名保护令牌完整性,三者不能互相替代。Spring Security 密码存储说明
3. Spring Security 的主要组件¶
Spring Security 是 Spring 生态的安全框架,提供认证、授权和 Web 防护组件;它不是用户数据库。
| 类型 | 初学者理解方式 | 职责 |
|---|---|---|
AuthenticationManager |
认证任务入口 | 接收待认证的凭据,返回认证结果或抛出失败异常 |
ProviderManager |
常用的认证管理器实现 | 把认证请求交给支持该类型的 Provider |
DaoAuthenticationProvider |
用户名密码认证执行者 | 加载账号,再用编码器校验密码 |
UserDetailsService |
账号资料加载器 | 返回账号资料,不负责比较输入密码 |
PasswordEncoder |
密码编码与匹配工具 | 生成密码哈希并验证输入密码 |
UserDetails |
账号资料契约 | 提供用户名、密码哈希、权限和账号状态 |
Authentication |
一次认证的输入或结果 | 保存主体、凭据、权限和认证状态 |
| principal | 认证主体 | 代表被认证的用户或其他身份,具体类型取决于认证方式 |
SecurityContext |
当前认证结果的上下文 | 供请求后续授权使用,不是用户表或令牌数据库 |
4. 一次标准密码认证怎样执行¶
收到用户名和密码
→ AuthenticationManager / ProviderManager
→ DaoAuthenticationProvider
→ UserDetailsService 加载账号资料
→ PasswordEncoder.matches 核验密码
→ 返回已认证 Authentication,或抛出认证失败异常
用户加载服务可以从内存、数据库或其他受信来源取资料。找到账号只代表“查到了资料”,并不代表输入密码的人已经通过认证。
UsernamePasswordAuthenticationToken.unauthenticated(...) 是后端传递待验证凭据的 Java 对象,不是发给客户端的 JWT。Provider 负责密码验证,Controller 不应另写一套密码比较逻辑。官方认证链说明
认证成功后,ProviderManager 默认尝试擦除结果中的敏感凭据;自定义主体若需要清理自身密码字段,应正确实现 CredentialsContainer。这不代表请求字符串或 JVM 内所有副本均已清零,因此仍不能记录原始密码或完整认证对象。
对外的登录失败提示不应区分“用户不存在”和“密码错误”;数据库不可用等基础设施故障要按服务器错误处理,不能伪装成密码错误。
直接调用 authenticate() 不会自动签发 JWT,也不等于已经保存浏览器登录状态。 认证之后如何建立会话或签发令牌,是另一个步骤。Servlet 与响应式安全链如何保存和传播身份,应遵循各自的上下文机制,不能自己维护全局静态“当前用户”。
5. Session、Cookie 与持续登录¶
HTTP 请求不会自动保留上一请求中 Java 方法的局部变量。持续登录需要客户端在后续请求携带可验证的凭据,让服务端恢复或建立可信身份。
- Session(会话):服务端保存的一段交互状态,例如已经通过认证的用户信息。
- Cookie:浏览器按域、路径等规则保存并自动发送的数据;常用其中的会话 ID 查找 Session,但 Cookie 本身不等于 Session。
- 令牌方式:客户端保存令牌并在业务请求中携带;其种类与用途见 各类 Token。
浏览器安全链是否跨请求保存身份,取决于上下文仓库、会话和过滤链配置。Servlet 通常通过 SecurityContextHolder 暴露本次请求的身份,并在请求结束时清理线程关联;响应式 WebFlux 使用响应式上下文,不能直接复制线程绑定方案。
Cookie 的自动发送特性与 CSRF 风险有关。使用 JWT 也不能直接推导系统完全无状态,或者一定不需要 CSRF 防护。用户退出、客户端清理凭据、服务端撤销会话、令牌到期是不同动作。
客户端不应长期保存原始密码;会话凭据也要避免泄露。移动系统安全存储与浏览器存储的能力不同,不能照搬同一保存方案。短期凭据到期后是否续期、如何续期,由登录协议和服务端策略决定。
6. 动手:先验证密码,不接 JWT 和数据库¶
目标:亲自观察“加载账号 → 校验密码 → 返回身份与权限”。先完成 Spring Boot 入门练习,在 Mac 的独立 hhjava-learning-basics 学习工程中操作。本练习只运行测试,不开放登录端点,不需要 Nacos、MySQL 或 RSA 密钥。
6.1 添加测试依赖¶
在学习工程 pom.xml 现有 <dependencies> 内追加:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<scope>test</scope>
</dependency>
本练习沿用 Spring Boot 入门工程的 Java 17 / Boot 3.3.7 基线,版本由父 POM 管理;这是学习工程的兼容约定,不是新项目的版本推荐。构造器按该基线编写,不混用不同版本文档的 API。test 范围只对测试可见,不给前面 /hello 接口自动加登录页。IDEA 重新加载 Maven。
6.2 创建完整认证测试¶
文件:src/test/java/com/example/learning/PasswordAuthenticationTest.java
package com.example.learning;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PasswordAuthenticationTest {
private final PasswordEncoder encoder = new BCryptPasswordEncoder();
private String rawPassword;
private ProviderManager manager;
@BeforeEach
void prepareOneLearningAccount() {
// 每次测试临时生成,不是项目账号密码,也不打印原文或哈希。
rawPassword = UUID.randomUUID().toString();
UserDetails user = User.withUsername("learning-user")
.password(encoder.encode(rawPassword))
.authorities("book:read")
.build();
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager(user);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(encoder);
provider.setUserDetailsService(users);
manager = new ProviderManager(provider);
}
@Test
void shouldAuthenticateAndEraseCredentials() {
Authentication result = manager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated("learning-user", rawPassword));
assertTrue(result.isAuthenticated());
assertEquals("learning-user", result.getName());
assertTrue(result.getAuthorities().stream()
.anyMatch(authority -> authority.getAuthority().equals("book:read")));
assertNull(result.getCredentials());
assertNull(((UserDetails) result.getPrincipal()).getPassword());
}
@Test
void shouldRejectIncorrectPasswordAndUnknownUser() {
assertThrows(BadCredentialsException.class, () -> manager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated("learning-user", rawPassword + "x")));
assertThrows(BadCredentialsException.class, () -> manager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated("missing-user", rawPassword)));
}
@Test
void shouldUseSaltAndMatchesInsteadOfComparingTwoHashes() {
String firstHash = encoder.encode(rawPassword);
String secondHash = encoder.encode(rawPassword);
assertNotEquals(firstHash, secondHash);
assertTrue(encoder.matches(rawPassword, firstHash));
assertTrue(encoder.matches(rawPassword, secondHash));
}
}
@BeforeEach 在每个测试前准备独立账号;InMemoryUserDetailsManager 用内存资料代替数据库,只是练习的 UserDetailsService。User 是框架实现的账号资料类型,不是业务数据库实体。book:read 是本练习约定的教学权限,不代表其他项目已经具备该权限。
6.3 运行并检查¶
在学习工程根目录执行:
mvn -Dtest=PasswordAuthenticationTest test
预期 BUILD SUCCESS。这同时验证:正确密码产生可信认证结果;错误密码和未知用户都失败;认证结果中的凭据被擦除;同一密码每次 BCrypt 编码通常不同,但 matches 都能验证。
若找不到 Security 类,检查依赖是否加载、测试是否误放进 src/main/java。若数据库密码认证失败,不要直接调用 encode(input).equals(storedHash);应该让共享 Provider 调用 matches。
6.4 实验结束后应该理解什么¶
认证测试成功,说明配置的 Provider 能核验输入并返回身份,不说明已经实现注册接口、数据库用户存储、浏览器会话或 JWT 签发。
把内存账号替换为数据库加载时,应保持标准 UserDetailsService → DaoAuthenticationProvider → PasswordEncoder 分工。将认证结果序列化或持久化还需要安全控制与读回兼容,见 序列化;不要为解决类型错误开放任意类型反序列化。
真实项目的账号模型、主体实现和签发步骤,应跟随项目自己的实战文档,不能把教学用户和权限直接当作生产数据。
7. 验证码和其他登录方式先学什么¶
下面介绍其他凭据类型与设计边界,不表示某个业务项目已经实现这些接口。
- 图形/交互验证码主要用于增加自动化滥用成本,不能单独证明某个账号归你所有;校验材料应由服务端关联本次挑战,并限制有效期、尝试次数和重用。
- 手机短信验证码属于 OTP(One-Time Password,一次性密码)的一种使用形式,主要证明本次能接收某号码的消息;发送、校验、失效、限流与账号绑定是不同步骤,不能只判断“输入了六位数字”。
- 微信、Apple 登录要由后端按提供方协议验证凭据并完成本地账号关联,不能信任客户端直接传来的第三方用户 ID。
- MFA(Multi-Factor Authentication,多因素认证)结合不同类别的证明材料;密码加上同一密码的另一种编码不是第二个因素。
扩展前先写清“凭据从哪里来、谁验证、是否只能用一次、对应哪个本地账号、失败如何防滥用”,再选择 Provider 或独立验证组件。现有注册、密码验证、JWT 签发规则不应因此复制成多套。