跳转至

认证和授权:基础原理与 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 签发规则不应因此复制成多套。