做了这么多年Spring Security相关的项目,我发现自己经常被问到同一个问题:OAuth2.0登录成功后,怎么拿到当前用户是谁?很多同学照着文档配置好了资源服务器,发现SecurityContextHolder.getContext().getAuthentication()能拿到东西,但取出来的principal却不是自己以为的UserDetails,甚至直接就是字符串,一脸懵。
这篇文章就围绕"获取登录用户身份信息"这个话题,把底层逻辑、几种实现方案、代码示例和踩坑记录一次说清楚。如果你想在自己的OAuth2.0项目里优雅地把登录用户身份拿到手,并且知道每一步为什么这么做,这篇值得花几分钟看完。
1. 身份信息的底层逻辑:从SecurityContext说起
1.1 认证成功后到底发生了什么
理解"获取登录用户身份"之前,先搞清楚Spring Security在认证成功后往哪里存东西。整个安全框架的核心就是SecurityContextHolder,它默认使用ThreadLocal来保存SecurityContext。SecurityContext里只有一个关键属性——Authentication接口的实现对象。
当一个请求通过OAuth2.0资源服务器的认证流程后,Spring Security会创建一个Authentication对象并放进当前线程的SecurityContext。这个Authentication接口主要包含几个部分:
principal:身份主体,通常代表"用户是谁"credentials:凭证信息,通常是密码或token,认证后一般会被清空authorities:权限集合,比如ROLE_ADMIN、SCOPE_readdetails:附加细节,可能是IP、sessionId等
很多人的第一个误区就在这里:以为principal一定是自己的UserDetails实现类。在传统session登录方式下确实如此——你自定义的UserDetailsService返回的对象会成为principal。但在OAuth2.0的JWT模式下,情况完全不一样,principal很可能就是一个Jwt对象,或者JwtAuthenticationToken本身。
1.2 OAuth2.0资源服务器中的Authentication到底是什么
如果你用的是Spring Security对OAuth2.0资源服务器的支持(oauth2ResourceServer配置),默认情况下,请求携带的JWT会被解析,然后框架构建一个JwtAuthenticationToken实例放入SecurityContext。
这个JwtAuthenticationToken的principal就是解析后的Jwt对象。Jwt内部包含:
claims:JWT的payload部分,比如sub、aud、scope、自定义字段headers:JWT的header信息tokenValue:原始token字符串
所以,如果你直接写:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Object principal = authentication.getPrincipal();这个principal的类型是Jwt,不是UserDetails。想拿用户ID或用户名,得从Jwt的claims里取,比如jwt.getClaims().get("sub")。
是不是觉得这设计有点绕?其实好理解。OAuth2.0本身是"授权"协议,不关心你业务系统里用户表长啥样,它只负责确认"这个token是否合法、包含哪些scope"。至于token里的sub代表什么含义,是用户ID还是用户名,那是业务自己决定的。Spring Security不替你做主观猜测,所以默认只给你原始JWT结构。
1.3 一个常用的自检方法
搞清楚当前上下文里Authentication的真实类型,最高效的办法就是打印出来看一眼。我通常在过滤器的最后面或者某个临时接口里写一段调试代码:
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); System.out.println("auth class: " + auth.getClass()); System.out.println("principal class: " + auth.getPrincipal().getClass()); System.out.println("principal: " + auth.getPrincipal());启动项目,带上token调一下接口,控制台会非常清楚地告诉你当前principal到底是什么类型。很多时候你用错类型强转导致ClassCastException,根源就是没做过这个检查。先确认类型,再决定怎么处理,能省去不少排查时间。
2. 获取身份信息的三种方式选型对比
明白了底层结构,接下来看实际获取用户信息的方案。我根据项目复杂度由低到高,梳理出三种常见方式。
2.1 方式一:直接从SecurityContextHolder取Authentication
这种方式最简单,也是Spring Security项目里最常见的做法。在任意被Spring Security拦截的接口中,通过SecurityContextHolder拿到Authentication,再从principal里提取信息。
@GetMapping("/me") public Map<String, Object> me() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication instanceof JwtAuthenticationToken jwtAuthenticationToken) { Jwt jwt = (Jwt) jwtAuthenticationToken.getPrincipal(); return jwt.getClaims(); } return Map.of("error", "not authenticated"); }这个方案的优点是零配置、上手快,适合接口里只需要JWT自身携带的字段(比如sub、username)的场景。缺点也很明显:你拿到的是原始JWT对象,业务代码需要自己解析字段、自己处理类型转换,一旦有多个接口要取用户信息,会大量重复代码,而且Jwt对象直接暴露在业务层,耦合度高。
2.2 方式二:通过JwtAuthenticationConverter自定义Principal
Spring Security的oauth2ResourceServer配置允许通过jwt()方法自定义JWT的解析策略,其中最关键的就是定制JwtAuthenticationConverter。这个转换器负责把JWT解析结果Jwt转换为Authentication对象。
默认的JwtAuthenticationConverter只做一件事:把scope或scpclaim解析为GrantedAuthority列表。如果你需要把principal变成自己的用户对象,就自己写一个Converter<Jwt, AbstractAuthenticationToken>。
我自己最常用的做法是:
- 在授权服务器签发token时,把用户关键信息(如用户ID、用户名、部门ID)放进自定义claim
- 资源服务器写一个
JwtAuthenticationConverter,从claims中提取这些字段,构建一个自定义的UserPrincipal对象 - 将
UserPrincipal包装进UsernamePasswordAuthenticationToken或自定义Authentication实现
这样做的好处是,业务代码里的principal就是你的领域模型了,取用户信息再也不跟Jwt的claims细节纠缠在一起。
2.3 方式三:自定义Authentication并携带领域对象
如果你的项目登录状态不只是简单的"用户名+权限",而是要携带更多运行时需要的上下文信息(比如当前用户的所属组织、数据权限范围、角色映射后的操作权限),那就值得定义自己的Authentication实现,或者至少定义自己的principal,把领域对象塞进去。
常见设计是让principal继承UserDetails并额外携带业务字段:
public class LoginUser implements UserDetails { private Long userId; private String username; private String deptId; private List<String> permissions; // 省略getter/setter }这样,在接口里拿到LoginUser后,可以非常自然地进行业务操作。相比方式一和方式二,方式三更贴近业务系统设计,也是我写中大型项目时的首选。
2.4 方案对比速查表
| 方案 | 代码侵入性 | 业务层耦合度 | 灵活性 | 适用场景 |
|---|---|---|---|---|
| 方式一:原始JWT | 低 | 高 | 低 | 快速验证、简单查询 |
| 方式二:自定义Converter | 中 | 中 | 中 | 大多数业务系统 |
| 方式三:自定义Authentication + 领域对象 | 较高 | 低 | 高 | 中大型系统、复杂权限业务 |
依赖注入或工具类封装后,方式一、方式二、方式三在接口代码里可以做到长得差不多,但底层的健壮性和扩展性差别很大。后面我会详细展开最推荐的方案二和方案三的实现细节。
3. 实操:完整构建登录用户身份信息
这一节是我这篇博文的核心。我用一个实际项目里的简化例子,走一遍从token到业务代码拿到LoginUser的完整流程。场景是:授权服务器签发的JWT里带user_id、user_name、dept_id三个自定义claim,资源服务器需要把这些信息塞进LoginUser,让业务接口直接可用。
3.1 定义用户信息载体
先定义一个LoginUser类,它同时承载用户身份和权限信息,实际项目里我通常会让它实现UserDetails接口,方便Spring Security直接识别:
public class LoginUser implements UserDetails { private Long userId; private String username; private String deptId; private List<String> permissions; @Override public Collection<? extends GrantedAuthority> getAuthorities() { if (permissions == null) { return List.of(); } return permissions.stream() .map(SimpleGrantedAuthority::new) .toList(); } @Override public String getPassword() { return null; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } // 其他getter/setter略 }这里有个重点:为什么getPassword()返回空而权限却从permissions转换?因为OAuth2.0的JWT登录场景下,密码凭证根本不经过资源服务器,资源服务器只认token有效性。UserDetails接口的getPassword()在这个模式下没有意义。而权限集合则需要根据业务需要自行填充,它决定了@PreAuthorize("hasAuthority('...')")这类注解能否生效。
3.2 编写JWT与LoginUser的转换器
核心逻辑在Converter<Jwt, AbstractAuthenticationToken>里。这个转换器负责把JWT解析出来的claims变成AuthenticationToken:
public class JwtToLoginUserConverter implements Converter<Jwt, AbstractAuthenticationToken> { @Override public AbstractAuthenticationToken convert(Jwt jwt) { LoginUser loginUser = new LoginUser(); Object userIdClaim = jwt.getClaim("user_id"); if (userIdClaim instanceof Number number) { loginUser.setUserId(number.longValue()); } loginUser.setUsername(jwt.getClaimAsString("user_name")); loginUser.setDeptId(jwt.getClaimAsString("dept_id")); // 从 JWT 的 scope 或自定义权限 claim 构建权限列表 List<String> permissions = buildPermissions(jwt); loginUser.setPermissions(permissions); // 注意:这里可以传入一个空密码,因为JWT模式下资源服务器不校验密码 return new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); } private List<String> buildPermissions(Jwt jwt) { // 场景1:权限来自scope List<String> scopes = jwt.getClaimAsStringList("scope"); // 场景2:权限来自自定义claim,比如"permissions" List<String> permissions = jwt.getClaimAsStringList("permissions"); if (permissions != null && !permissions.isEmpty()) { return permissions; } if (scopes != null) { return scopes.stream() .map(scope -> "SCOPE_" + scope) .toList(); } return List.of(); } }这段代码里有几个细节值得注意。第一,user_id的取值用了Number判断,因为不同授权服务器或不同语言生成的JWT,数字可能在JSON里被解析成Integer、Long或BigInteger,直接强转会翻车,经Number中转是最稳的。第二,权限的构建策略我合并了两种场景:有些系统用scope做粗粒度权限,有些系统用自定义claim做细粒度权限,可以根据实际情况灵活调整。
3.3 接入Spring Security过滤链
有了转换器,还需要把它配置到资源服务器上。下面是SecurityFilterChain的配置示例:
@Configuration @EnableWebSecurity public class ResourceServerConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtToLoginUserConverter()) ) ); return http.build(); } @Bean public Converter<Jwt, AbstractAuthenticationToken> jwtToLoginUserConverter() { return new JwtToLoginUserConverter(); } }注意oauth2ResourceServer().jwt()和直接自定义JwtDecoder是两回事。jwtAuthenticationConverter管的是"JWT解析后如何转成Authentication",而JwtDecoder管的是"token字符串如何变成Jwt对象"。日常开发中,JwtDecoder关注签名验证、密钥配置;JwtAuthenticationConverter关注claims到认证对象的映射。这两者不要混了,否则改配置时容易找错位置。
3.4 在业务代码里获取登录用户
配置完成后,业务类里取用户就非常清爽了:
@RestController public class UserController { @GetMapping("/user/info") public UserInfoVO getUserInfo() { LoginUser loginUser = SecurityUtils.getCurrentUser(); return userService.getUserInfo(loginUser.getUserId(), loginUser.getDeptId()); } }配合一个简单的静态工具类:
public final class SecurityUtils { private SecurityUtils() {} public static LoginUser getCurrentUser() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) { throw new RuntimeException("当前登录用户不存在"); } return loginUser; } }这里用了instanceof模式匹配,顺带做了类型检查,比直接强转安全得多。实际项目中,我还会在工具类里判断authentication.isAuthenticated(),避免匿名用户走到这里。
3.5 关于授权服务器如何签发对应claim的说明
有资源服务器侧的转换逻辑,自然就涉及授权服务器侧怎么把自定义claim签进JWT。很多同学在这里卡住,我简单说一下思路。
在授权服务器里,如果你用的是自定义token增强器,核心就是给JWT的claims塞值。以常见的实现为例:
public class CustomTokenEnhancer implements TokenEnhancer { @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { Map<String, Object> additionalInfo = new HashMap<>(); Object principal = authentication.getPrincipal(); if (principal instanceof UserInfo user) { additionalInfo.put("user_id", user.getUserId()); additionalInfo.put("user_name", user.getUsername()); additionalInfo.put("dept_id", user.getDeptId()); } DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(accessToken); token.setAdditionalInformation(additionalInfo); return token; } }要点是:authentication.getPrincipal()的类型在授权服务器里通常是你登录流程构建的UserDetails或其他用户对象,你需要在那里把业务用户信息取出来放进map。定的claim名称要和资源服务器转换器里读的一致,这个"约定"贯穿授权、认证、资源校验全程。我在项目里会单独定义一个constants类统一管理claim的key,防止两边写串。
4. 踩坑记录与问题排查实录
方案可以设计得很漂亮,但落地过程中一定会遇到各种问题。我整理了这些年做OAuth2.0登录用户信息获取时踩过的高频坑,每个都有对应的排查思路。
4.1 ClassCastException与类型安全
不少同学在接口里直接写:
LoginUser user = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();然后运行时抛出ClassCastException。原因基本有两种:一种是根本没有配置jwtAuthenticationConverter,principal是Jwt当然转不了LoginUser;另一种是配置了但路径不对,比如配置在了一个没有被请求经过的过滤器链上。
排查方法很简单,用我在1.3部分提到的调试代码先打印principal.getClass(),看实际类型。确认是Jwt就说明转换器没生效,往过滤器链配置方向排查;确认是LoginUser却转不了,那可能是classloader或包结构问题,但那种情况极少,核心还是先看配置。
4.2 自定义claim突然丢失
授权服务器明明签发token时加了user_name、dept_id,但资源服务器解析出来的claims里没这些key。这个问题我碰过好几次,最常见的原因是token过期后重新签发时没有走增强器。比如客户端模式下某流程直接生成了token,没有经过TokenEnhancer。
另一个原因是某些授权服务器的JWT默认只保留标准claim(比如sub、iat、exp、scope),非标准claim需要显式配置。排查时先拿着token去解码(比如jwt.io手动粘贴看看payload里到底有啥),看自定义claim是否存在。如果token里没有,问题在授权服务器;如果token里有但代码读不到,才考虑资源服务器解析配置的问题。
4.3 异步线程中SecurityContext丢失
Spring Security默认通过ThreadLocal保存认证信息,所以一旦你使用@Async、CompletableFuture这些异步方式开启新线程,子线程默认是拿不到父线程的SecurityContext的。
这里说一下:ThreadLocal本身不是继承传递的,子线程里SecurityContextHolder.getContext()会是空。解决方案大致有三种,项目里我按情况选择:
- 方案A:在异步任务入口手动把
Authentication对象传进去,子线程里直接用这个对象 - 方案B:用
SecurityContextHolder.setContext()在子线程内重新设置SecurityContext,注意任务结束要清理 - 方案C:给
TaskDecorator设置SecurityContext传递逻辑
项目里最常用的其实就是给线程池配置TaskDecorator:
public class SecurityContextTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { SecurityContext context = SecurityContextHolder.getContext(); return () -> { try { SecurityContextHolder.setContext(context); runnable.run(); } finally { SecurityContextHolder.clearContext(); } }; } }然后在线程池构造器里传进去:
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setTaskDecorator(new SecurityContextTaskDecorator());这个解法很巧妙:父线程在提交任务前把当前SecurityContext捕获,子线程执行时先set进去,用完后clear掉。既保证了子线程里有用户上下文,又不至于内存泄漏或上下文污染。
4.4 常见问题速查表
| 问题现象 | 根本原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| principal类型不是期望的UserDetails | 未配置JwtAuthenticationConverter | 打印principal.class | 自定义Converter |
| 拿到的用户对象字段为null | JWT里没有对应claim | 解码token查payload | 授权服务器增加claim |
| 接口突然返回403 | scope不足或没有对应权限 | 查看异常日志中的access denied | 调整JWT权限claim映射 |
| 异步方法里取不到用户 | ThreadLocal不跨线程 | 在异步方法入口打日志 | TaskDecorator传递SecurityContext |
| 每次请求都查一次用户表 | 设计问题 | 监控SQL日志 | 缓存用户信息 |
| JWT解析时报错 | JwtDecoder密钥配置错误 | 检查签名算法和密钥 | 配置正确的JwtDecoder |
4.5 一个隐藏比较深的坑:转换器只对Bearer Token生效
JwtAuthenticationConverter只作用于Authorization: Bearer xxx这种请求头携带token的场景。如果你在过滤器链前面还加了一些自定义认证逻辑,或者在网关层把token转成了其他头传递,资源服务器根本不会触发JWT处理。
这个坑比较隐蔽,因为接口偶尔能通(比如某些放行路径绕过认证),大部分路径却全部401。排查时直接看请求头是否是标准的Authorization: Bearer。如果是网关透传场景,建议统一透传原始请求头,不要在中间层二次封装token。
5. 实践中的设计建议
5.1 身份信息放在JWT里还是从数据库加载
关于JWT里放哪些字段,我在团队评审时经常强调一句话:JWT适合放"不敏感、生命周期与token一致、被广泛需要"的信息,不适合放会变动的信息。
举个例子,用户昵称可以放JWT(算半公开信息,可接受),但用户的权限列表如果频繁变动,放JWT里就会很尴尬——改了权限,老token在过期前依然携带旧权限。这时更适合只在JWT里放user_id,每次请求时从缓存或数据库加载最新权限。
实操中的折衷方案是双轨制:JWT携带基础身份(user_id、用户名),细粒度的角色权限信息用缓存管理,key为user_id,token解析后统一从缓存组装LoginUser。这样既避免了每次查库压力,又能保证权限相对实时。
5.2 避免在业务层到处依赖SecurityContextHolder
我在评审代码时,最反感的是业务Service层里直接出现SecurityContextHolder.getContext()这样的调用。不是说不能写,而是这么写会让业务逻辑强依赖Spring Security的上下文环境,单元测试时要mock一堆东西,非常痛苦。
我推荐的做法是,在Controller层或应用服务层入口处把当前登录用户解析成领域对象,然后显式传给下面的Service方法:
public void updateProfile(LoginUser currentUser, UpdateProfileCommand command) { userProfileService.update(currentUser.getUserId(), command); }如果嫌每层传参太啰嗦,可以在Service方法签名里做一个参数对象封装(比如CurrentUser上下文类),至少比散落着静态调用好维护。方法签名里写明登录用户依赖,代码意图也更清楚。
5.3 不要为"拿用户信息"去扩展Authentication接口的职责
有些同学为了图方便,会把一些和认证无关的业务数据(比如购物车数量、当前环境变量)都往Authentication或SecurityContext里塞。我劝你不要这么做。
SecurityContext存在的意义是承载认证与授权上下文,不是万能业务数据缓存。把业务数据塞进去,很快会遇到这些问题:异步线程里这些数据丢不丢、泄漏到日志里合不合适、序列化方案怎么处理。用户信息够用就行,多余的业务数据放在业务自己的上下文里管理。
5.4 微服务场景下identity的传递
如果你做的是微服务架构,单个资源服务器拿到登录用户只是第一步,更常见的问题是如何在服务间传递用户身份。通常的做法是:网关解析token,把用户信息放进转发请求的header(比如X-User-Id),下游服务从header里读取。这时下游服务就不该再配置OAuth2.0资源服务器解析逻辑,否则会重复解析。
这里有一个取舍:如果下游服务也需要权限校验,应该只信任网关传递的header,但必须保证这个header只能由网关写入,外部请求无法伪造。实现上需要在网关侧做过滤,并把内部服务的网络隔离做好。
6. 写在最后的实践经验
坦白说,获取登录用户身份信息这件事,表面上看就是把principal取出来,但真正做好的关键在于搞清楚你的业务系统需要什么粒度的用户信息,再把token解析、权限映射、异常处理和线程传递这些环节串起来。
我个人最偏爱的组合是:授权服务器在JWT里只放不可变的核心身份(user_id、user_name),资源服务器用自定义JwtAuthenticationConverter把这些映射为LoginUser,权限部分按需从缓存加载,异步场景靠TaskDecorator传递SecurityContext。这套组合在多个项目里验证过,扩展性、可维护性和排查问题的效率都不错。
如果你现在还只是用默认配置、直接解析原始JWT,也不用急着推翻重来。先把principal的实际类型、自己业务真正需要的字段列出来,再考虑要不要引入自定义转换器。一步一步来,身份信息获取这条链路就会越来越顺手。