跳到正文
hello world

从Spring Security登录认证到基本的RBAC

学习 Spring Security 时,我最开始觉得它特别乱。

一会儿是 AuthenticationManager,一会儿是 AuthenticationProvider,接着又冒出 UserDetailsServiceUserDetailsAuthenticationToken。如果再加上 JWT、短信登录、微信登录和 RBAC,整个流程很容易被绕晕。

后来我发现,理解 Spring Security 不能一开始就背配置,而是要先抓住一条主线:

用户提交登录信息,Spring Security 找到对应的认证方式进行校验,认证成功后保存当前用户身份,后续请求再根据用户拥有的权限决定能否访问接口。

围绕这条主线,很多类就不再是零散的概念了。


一、Spring Security 主要解决什么问题

Spring Security 主要解决两件事:

认证 Authentication
授权 Authorization

认证解决的是:

你是谁?

例如验证:

  • 用户名和密码是否正确
  • 手机号和验证码是否正确
  • 微信授权信息是否有效

授权解决的是:

你能做什么?

例如:

  • 能不能新增用户
  • 能不能删除菜单
  • 能不能查看财务数据
  • 能不能进入后台管理页面

因此,一个完整的安全流程可以分成两段:

登录阶段:确认用户身份

访问接口阶段:检查用户权限

二、账号密码登录的完整流程

假设登录接口接收用户名和密码:

@PostMapping("/login")
public String login(@RequestBody LoginRequest request) {

    Authentication authentication =
            authenticationManager.authenticate(
                    UsernamePasswordAuthenticationToken.unauthenticated(
                            request.getUsername(),
                            request.getPassword()
                    )
            );

    return jwtService.generateToken(authentication);
}

这里最重要的不是 JWT,而是下面这段:

authenticationManager.authenticate(
        UsernamePasswordAuthenticationToken.unauthenticated(
                username,
                password
        )
);

完整流程如下:

Controller
    ↓
创建 UsernamePasswordAuthenticationToken
    ↓
AuthenticationManager
    ↓
ProviderManager
    ↓
DaoAuthenticationProvider
    ↓
UserDetailsService
    ↓
查询数据库用户
    ↓
PasswordEncoder 校验密码
    ↓
返回认证成功的 Authentication

三、AuthenticationToken 是什么

下面这段代码:

new UsernamePasswordAuthenticationToken(username, password)

并不是在执行登录。

它只是创建了一个认证对象,用来表示:

这是一次用户名密码认证请求。

认证前,这个对象中大致保存:

principal   = 用户名
credentials = 密码
authenticated = false

可以把它理解成一张登录申请单:

申请人:张三
认证材料:密码
认证方式:用户名密码

真正触发认证的是:

authenticationManager.authenticate(token);

所以:

AuthenticationToken:描述本次使用什么方式登录

AuthenticationManager:正式发起认证

四、AuthenticationManager 是什么

AuthenticationManager 是认证的统一入口:

Authentication authenticate(Authentication authentication);

业务代码不需要自己决定具体由谁校验,只需要把认证对象交进去:

authenticationManager.authenticate(token);

实际项目中,常见实现是:

ProviderManager

ProviderManager 内部管理多个 AuthenticationProvider

例如:

ProviderManager
├── DaoAuthenticationProvider
├── SmsAuthenticationProvider
└── 其他 AuthenticationProvider

它的作用类似于调度中心:

根据当前传入的 Token 类型,找到能够处理它的 Provider。


五、Spring Security 怎么找到对应的 Provider

这是我之前最困惑的地方。

Spring Security 不是根据接口地址判断,也不是根据参数名判断,而是通过每个 Provider 的:

supports()

例如短信认证 Provider:

@Override
public boolean supports(Class<?> authentication) {
    return SmsAuthenticationToken.class
            .isAssignableFrom(authentication);
}

当传入:

new SmsAuthenticationToken(mobile, code)

ProviderManager 会遍历所有 Provider:

for (AuthenticationProvider provider : providers) {
    if (provider.supports(authentication.getClass())) {
        return provider.authenticate(authentication);
    }
}

大致判断过程是:

传入 SmsAuthenticationToken
        ↓
DaoAuthenticationProvider.supports() = false
        ↓
SmsAuthenticationProvider.supports() = true
        ↓
调用 SmsAuthenticationProvider.authenticate()

所以一句话:

Token 决定登录类型,Provider 的 supports() 决定谁来处理。


六、AuthenticationProvider 是什么

AuthenticationProvider 才是真正执行认证逻辑的地方。

接口中最核心的是两个方法:

public interface AuthenticationProvider {

    Authentication authenticate(
            Authentication authentication
    );

    boolean supports(
            Class<?> authentication
    );
}

其中:

supports():判断自己是否支持这种 Token

authenticate():真正执行认证

账号密码登录通常由 Spring Security 提供的:

DaoAuthenticationProvider

负责处理。

短信验证码登录通常需要自己实现:

SmsAuthenticationProvider

因此,不同登录方式的结构可以理解为:

UsernamePasswordAuthenticationToken
    ↓
DaoAuthenticationProvider

SmsAuthenticationToken
    ↓
SmsAuthenticationProvider

没有现成登录方式时,一般需要:

  1. 自定义一个 Token
  2. 自定义一个 Provider

七、UserDetailsService 为什么存在

Spring Security 不知道我们的用户表结构。

有的项目叫:

sys_user

有的项目叫:

user

用户名字段可能叫:

username
login_name
account
mobile

密码字段也可能完全不同。

所以 Spring Security 定义了一个接口:

public interface UserDetailsService {

    UserDetails loadUserByUsername(String username);
}

它的意思其实不是限定只能使用用户名,而是:

给我一个账号标识,请把这个用户查询出来。

账号标识可以是:

  • 用户名
  • 邮箱
  • 手机号
  • 工号

例如:

@Override
public UserDetails loadUserByUsername(String account) {

    UserEntity user = userMapper.selectOne(
            Wrappers.<UserEntity>lambdaQuery()
                    .eq(UserEntity::getUsername, account)
    );

    if (user == null) {
        throw new UsernameNotFoundException("用户名或密码错误");
    }

    return User.withUsername(user.getUsername())
            .password(user.getPassword())
            .authorities(loadAuthorities(user.getId()))
            .build();
}

UserDetailsService 的核心作用就是:

把自己的 UserEntity

转换成

Spring Security 能识别的 UserDetails

八、loadUserByUsername 的参数从哪里来

假设登录时创建:

UsernamePasswordAuthenticationToken.unauthenticated(
        username,
        password
);

其中:

principal = username
credentials = password

DaoAuthenticationProvider 会从 Token 中获取账号:

String username = authentication.getName();

然后调用:

userDetailsService.loadUserByUsername(username);

因此参数传递过程是:

前端提交 username
    ↓
LoginRequest.username
    ↓
UsernamePasswordAuthenticationToken.principal
    ↓
DaoAuthenticationProvider
    ↓
loadUserByUsername(username)

这个参数不是 Spring Security 凭空产生的,而是我们创建 Token 时传进去的。


九、密码是怎么校验的

UserDetailsService 只负责查询用户,不负责手动比较密码。

数据库中保存的应该是 BCrypt 密文,例如:

$2a$10$...

返回 UserDetails 时,直接放数据库中的密文:

return User.withUsername(user.getUsername())
        .password(user.getPassword())
        .authorities(authorities)
        .build();

不要再次执行:

passwordEncoder.encode(user.getPassword())

否则相当于把数据库中的 BCrypt 密文再次加密,密码必然校验失败。

真正的密码比较由 DaoAuthenticationProvider 调用:

passwordEncoder.matches(
        用户输入的明文密码,
        数据库中的加密密码
);

参数顺序一定是:

matches(明文, 密文)

例如:

passwordEncoder.matches(
        "123456",
        "$2a$10$..."
);

十、认证成功后返回了什么

认证前创建的 Token 通常是:

principal = username
credentials = password
authenticated = false

认证成功后,Provider 会返回一个新的 Authentication

principal = UserDetails
credentials = null
authorities = 当前用户权限
authenticated = true

也就是说,认证成功后的 Authentication 不再只是保存用户名和密码,而是保存完整的登录用户信息。

可以通过:

authentication.getPrincipal();
authentication.getName();
authentication.getAuthorities();

获取:

  • 当前用户
  • 用户名
  • 角色和权限

十一、SecurityContextHolder 的作用

这里的本质还是ThreadLocal 存储的

认证成功后,需要让后面的代码知道当前用户是谁。

Spring Security 会把认证信息保存到:

SecurityContextHolder

结构大致是:

SecurityContextHolder
    ↓
SecurityContext
    ↓
Authentication
    ↓
principal + authorities

后续代码就可以获取当前用户:

Authentication authentication =
        SecurityContextHolder.getContext()
                .getAuthentication();

例如:

String username = authentication.getName();

在实际业务中,通常还会自定义一个 LoginUser,里面保存:

userId
tenantId
username
部门
角色
权限

然后通过:

LoginUser loginUser =
        (LoginUser) authentication.getPrincipal();

获取当前用户的业务信息。


十二、多种登录方式怎么扩展

假设系统同时支持:

用户名 + 密码

手机号 + 验证码

一般使用同一个:

AuthenticationManager

但是使用不同的 Token 和 Provider。

账号密码登录:

authenticationManager.authenticate(
        UsernamePasswordAuthenticationToken.unauthenticated(
                username,
                password
        )
);

对应:

DaoAuthenticationProvider

手机号验证码登录:

authenticationManager.authenticate(
        new SmsAuthenticationToken(
                mobile,
                code
        )
);

对应:

SmsAuthenticationProvider

所以不是创建两个 AuthenticationManager,而是:

一个 AuthenticationManager
多个 AuthenticationProvider

可以用下面三个概念记忆:

Token:拿什么材料登录

Provider:怎么验证这些材料

AuthenticationManager:把材料交给对应的 Provider

十三、RBAC

认证解决用户是谁,RBAC 解决用户有什么权限。

RBAC 的核心关系是:

用户
  ↓
角色
  ↓
权限

例如:

张三
  ↓
管理员
  ↓
新增用户
删除用户
查看菜单

企业项目中常见的表其实是五张:

sys_user

sys_role

sys_permission

sys_user_role

sys_role_permission

其中:

sys_user_role

表示用户拥有哪些角色。

sys_role_permission

表示角色拥有哪些权限。


十四、权限表里存什么

权限表中通常会保存权限标识,例如:

sys:user:list
sys:user:add
sys:user:update
sys:user:delete
sys:menu:list

这里保存的不是接口地址,而是业务权限编码。

例如:

sys:user:add

表示:

系统管理模块下,新增用户的权限。

登录时,根据用户 ID 查询角色和权限:

user
  ↓
user_role
  ↓
role
  ↓
role_permission
  ↓
permission

最后得到:

ROLE_ADMIN
sys:user:list
sys:user:add
sys:user:delete
sys:menu:list

十五、角色和权限怎么放进 authorities

Spring Security 最终并不关心我们的数据库表。

它只关心:

Collection<? extends GrantedAuthority>

一般会把角色和权限转换成:

SimpleGrantedAuthority

例如:

List<GrantedAuthority> authorities =
        new ArrayList<>();

authorities.add(
        new SimpleGrantedAuthority("ROLE_ADMIN")
);

authorities.add(
        new SimpleGrantedAuthority("sys:user:add")
);

authorities.add(
        new SimpleGrantedAuthority("sys:user:delete")
);

然后放进 UserDetails

return User.withUsername(user.getUsername())
        .password(user.getPassword())
        .authorities(authorities)
        .build();

最终 authorities 中保存的其实都是字符串:

ROLE_ADMIN
sys:user:add
sys:user:delete
sys:menu:list

十六、Security 怎么区分角色和权限

从对象类型上来说,角色和权限没有区别。

它们都是:

SimpleGrantedAuthority

Spring Security 主要通过字符串前缀和判断方法区分。

角色通常约定以:

ROLE_

开头。

例如:

ROLE_ADMIN
ROLE_MANAGER

普通权限则使用业务编码:

sys:user:add
sys:user:delete

当写:

@PreAuthorize("hasRole('ADMIN')")

Spring Security 会自动加上 ROLE_ 前缀,实际检查的是:

ROLE_ADMIN

所以:

hasRole("ADMIN")

大致等价于:

hasAuthority("ROLE_ADMIN")

而权限判断:

@PreAuthorize("hasAuthority('sys:user:add')")

会直接检查:

sys:user:add

因此可以理解为:

角色本质上也是一种 Authority,只不过角色按照约定使用了 ROLE_ 前缀。


十七、接口权限怎么控制

开启方法权限后:

@EnableMethodSecurity

可以直接在业务方法上使用:

@PreAuthorize("hasAuthority('sys:user:add')")
@PostMapping("/users")
public void addUser() {
}

Spring Security 会检查当前用户的 authorities 中是否存在:

sys:user:add

存在就允许执行,不存在就返回 403 Forbidden

也可以同时判断多个权限:

@PreAuthorize("""
    hasAuthority('sys:user:add')
    or hasRole('ADMIN')
""")

企业业务接口通常更适合按具体权限控制:

hasAuthority("sys:user:add")

因为角色比较粗。

同样是管理员,不同管理员拥有的操作权限也可能不同。


十八、JWT 在整个流程中的位置

用户名密码只在登录时校验一次。

认证成功后,系统签发 JWT:

登录成功
    ↓
生成 JWT
    ↓
返回前端

后续请求携带:

Authorization: Bearer xxx

JWT 过滤器负责:

读取请求头
    ↓
解析 JWT
    ↓
获取 userId 或 username
    ↓
恢复 LoginUser 和 authorities
    ↓
创建已认证 Authentication
    ↓
放入 SecurityContextHolder

因此,后续请求不需要再次提交密码。

不是因为 Spring Security 永远记住了密码,而是因为:

JWT 成为了后续请求的身份凭证。

JWT 中一般不建议存放大量权限,常见做法是只保存:

userId
username
tokenId

然后根据 userId 从 Redis 或数据库中恢复完整的 LoginUser 和权限列表。


十九、总结

Spring Security 的核心并不是大量配置,而是下面这条认证链:

前端提交登录信息
    ↓
创建 AuthenticationToken
    ↓
AuthenticationManager.authenticate()
    ↓
ProviderManager 遍历 Provider
    ↓
supports() 找到合适的 Provider
    ↓
Provider 执行认证
    ↓
查询用户或校验验证码
    ↓
返回认证成功的 Authentication
    ↓
保存到 SecurityContextHolder

授权链则是:

查询用户角色和权限
    ↓
转换成 GrantedAuthority
    ↓
放入 UserDetails / LoginUser
    ↓
放入 Authentication
    ↓
保存到 SecurityContextHolder
    ↓
@PreAuthorize 检查权限

多种登录方式也没有想象中那么复杂:

用户名密码
    ↓
UsernamePasswordAuthenticationToken
    ↓
DaoAuthenticationProvider
手机号验证码
    ↓
SmsAuthenticationToken
    ↓
SmsAuthenticationProvider
Token 表示登录方式

Provider 负责具体校验

AuthenticationManager 负责统一调度

二十、最后总结

学习 Spring Security 时,最容易陷入两个误区。

第一个误区是,一开始就背配置。

实际上,配置只有在理解认证流程后才有意义。

第二个误区是,把 JWT、RBAC、短信登录和 Spring Security 混成一件事。

它们各自负责不同的问题:

Spring Security:提供认证授权框架

JWT:保存和传递登录凭证

RBAC:管理用户、角色和权限关系

Redis:缓存登录用户、Token 状态和权限信息

最后,我把目前的理解压缩成几句话:

AuthenticationToken 表示这次使用什么方式登录。

AuthenticationProvider 负责验证这种登录方式。

AuthenticationManager 根据 Token 找到对应的 Provider。

UserDetailsService 把自己的用户数据转换成 Spring Security 能识别的用户。

GrantedAuthority 保存当前用户拥有的角色和权限。

SecurityContextHolder 保存当前请求中的登录身份。

JWT 让后续请求不需要再次提交用户名和密码。

RBAC 决定当前用户最终拥有哪一些 Authority。

理解了这些概念以后,再去看 Spring Security 的配置、过滤器和企业项目源码,就不会再觉得每个类都是突然冒出来的了。

评论

填写昵称与邮箱即可评论,无需登录。

推荐阅读