历史文章

Spring Security OAuth2.0认证授权一:框架搭建和认证测试

Spring Security OAuth2.0认证授权二:搭建资源服务

Spring Security OAuth2.0认证授权三:使用JWT令牌

Spring Security OAuth2.0认证授权四:分布式系统认证授权

上一篇文章讲解了如何在分布式系统环境下进行认证和鉴权,总体来说就是网关认证,目标服务鉴权,但是存在着一个问题:关于用户信息,目标服务只能获取到网关转发过来的username信息,为啥呢,因为认证服务颁发jwt令牌的时候就只存放了这么多信息,我们到jwt.io网站上贴出jwt令牌查看下payload中内容就就知道有什么内容了:

本篇文章的目的就是为了解决该问题,把用户信息(用户名、头像、手机号、邮箱等)放到jwt token中,经过网关解析之后携带用户信息访问目标服务,目标服务将用户信息保存到上下文并保证线程安全性的情况下封装成工具类提供给各种环境下使用。

注:本文章基于源代码https://gitee.com/kdyzm/spring-security-oauth-study/tree/v5.0.0分析和改造。

一、实现UserDetailsService接口

1.问题分析和修改

jwt令牌中用户信息过于少的原因在于认证服务auth-server中com.kdyzm.spring.security.auth.center.service.MyUserDetailsServiceImpl#loadUserByUsername 方法中的这段代码

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

这里User类实现了UserDetailsService接口,并使用建造者模式生成了需要的UserDetailsService对象,可以看到生成该对象仅仅传了三个参数,而用户信息仅仅有用户名和password两个参数———那么如何扩展用户信息就一目了然了,我们自己也实现UserDetailsService接口然后返回改值不就好了吗?不好!!实现UserDetailsService接口要实现它需要的好几个方法,不如直接继承User类,在改动最小的情况下保持原有的功能基本不变,这里定义UserDetailsExpand继承User

public class UserDetailsExpand extends User {
public UserDetailsExpand(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
//userId
private Integer id;
//电子邮箱
private String email;
//手机号
private String mobile;
private String fullname;
//Getter/Setter方法略
}

之后,修改com.kdyzm.spring.security.auth.center.service.MyUserDetailsServiceImpl#loadUserByUsername方法返回该类的对象即可

        UserDetailsExpand userDetailsExpand = new UserDetailsExpand(tUser.getUsername(), tUser.getPassword(), AuthorityUtils.createAuthorityList(array));
userDetailsExpand.setId(tUser.getId());
userDetailsExpand.setMobile(tUser.getMobile());
userDetailsExpand.setFullname(tUser.getFullname());
return userDetailsExpand;

2.测试修改和源码分析

修改了以上代码之后我们启动服务,获取jwt token之后查看其中的内容,会发现用户信息并没有填充进去,测试失败。。。。再分析下,为什么会没有填充进去?关键在于JwtAccessTokenConverter这个类,该类未发起作用的时候,返回请求放的token只是一个uuid类型(好像是uuid)的简单字符串,经过该类的转换之后就将一个简单的uuid转换成了jwt字符串,该类中的org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter#convertAccessToken方法在起作用,顺着该方法找下去:org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter#convertAccessToken,然后就发现了这行代码

response.putAll(token.getAdditionalInformation());

这个token就是OAuth2AccessToken对象,也就是真正返回给请求者的对象,查看该类中该字段的解释

/**
* The additionalInformation map is used by the token serializers to export any fields used by extensions of OAuth.
* @return a map from the field name in the serialized token to the value to be exported. The default serializers
* make use of Jackson's automatic JSON mapping for Java objects (for the Token Endpoint flows) or implicitly call
* .toString() on the "value" object (for the implicit flow) as part of the serialization process.
*/
Map<String, Object> getAdditionalInformation();

可以看到,该字段是专门用来扩展OAuth字段的属性,万万没想到JWT同时用它扩展jwt串。。。接下来就该想想怎么给OAuth2AccessToken对象填充这个扩展字段了。

如果仔细看JwtAccessTokenConverter这个类的源码,可以看到有个方法org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter#enhance,该方法有个参数OAuth2AccessToken accessToken,同时它的返回值也是OAuth2AccessToken,也就是说这个方法,传入了OAuth2AccessToken对象,完事儿了之后还传出了OAuth2AccessToken对象,再根据enhance这个名字,可以推测出,它是一个增强方法,修改了或者代理了OAuth2AccessToken对象,查看父接口,是TokenEnhancer接口

public interface TokenEnhancer {
/**
* Provides an opportunity for customization of an access token (e.g. through its additional information map) during
* the process of creating a new token for use by a client.
*
* @param accessToken the current access token with its expiration and refresh token
* @param authentication the current authentication including client and user details
* @return a new token enhanced with additional information
*/
OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication);
}

根据该注释可以看出该方法用于定制access_token,那么通过这个方法填充access token的AdditionalInformation属性貌似正合适(别忘了目的是干啥的)。

看下JwtAccessTokenConverter是如何集成到认证服务的

    @Bean
public AuthorizationServerTokenServices tokenServices(){
DefaultTokenServices services = new DefaultTokenServices();
services.setClientDetailsService(clientDetailsService);
services.setSupportRefreshToken(true);
services.setTokenStore(tokenStore);
services.setAccessTokenValiditySeconds(7200);
services.setRefreshTokenValiditySeconds(259200); TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Collections.singletonList(jwtAccessTokenConverter));
services.setTokenEnhancer(tokenEnhancerChain);
return services;
}

可以看到这里的tokenEnhancerChain可以传递一个列表,这里只传了一个jwtAccessTokenConverter对象,那么解决方案就有了,实现TokenEnhancer接口并将对象填到该列表中就可以了

3.实现TokenEnhancer接口

@Slf4j
@Component
public class CustomTokenEnhancer implements TokenEnhancer { @Autowired
private ObjectMapper objectMapper; @Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String,Object> additionalInfo = new HashMap<>();
Object principal = authentication.getPrincipal();
try {
String s = objectMapper.writeValueAsString(principal);
Map map = objectMapper.readValue(s, Map.class);
map.remove("password");
map.remove("authorities");
map.remove("accountNonExpired");
map.remove("accountNonLocked");
map.remove("credentialsNonExpired");
map.remove("enabled");
additionalInfo.put("user_info",map);
((DefaultOAuth2AccessToken)accessToken).setAdditionalInformation(additionalInfo);
} catch (IOException e) {
log.error("",e);
}
return accessToken;
}
}

以上代码干了以下几件事儿:

  • 从OAuth2Authentication对象取出principal对象
  • 转换principal对象为map并删除map对象中的若干个不想要的字段属性
  • 将map对象填充进入OAuth2AccessToken对象的additionalInfo属性

实现TokenEnhancer接口后将该对象加入到TokenEnhancerChain中

TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(customTokenEnhancer,jwtAccessTokenConverter));

4.接口测试

POST请求http://127.0.0.1:30000/oauth/token?client_id=c1&client_secret=secret&grant_type=password&username=zhangsan&password=123得到结果

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiZXhwIjoxNjEwNjM4NjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjFkOGY3OGFmLTg1N2EtNGUzMS05ODYxLTZkYWJjNjU4NzcyNiIsImNsaWVudF9pZCI6ImMxIn0.Y9f5psNCgZi_I2KY3PLBLjuK5-U1VhXIB1vjKjMb9fc",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiYXRpIjoiMWQ4Zjc4YWYtODU3YS00ZTMxLTk4NjEtNmRhYmM2NTg3NzI2IiwiZXhwIjoxNjEwODkwNjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjM1OGFkMzA1LTU5NzUtNGM3MS05ODI4LWQ2N2ZjN2MwNDMyMCIsImNsaWVudF9pZCI6ImMxIn0._bhajMIdqnUL1zgc8d-5xlXSzhsCWbZ2jBWlNb8m_hw",
"expires_in": 7199,
"scope": "ROLE_ADMIN ROLE_USER ROLE_API",
"user_info": {
"username": "zhangsan",
"id": 1,
"email": "123456@foxmail.com",
"mobile": "12345678912",
"fullname": "张三"
},
"jti": "1d8f78af-857a-4e31-9861-6dabc6587726"
}

可以看到结果中多了user_info字段,而且access_token长了很多,我们的目的是为了在jwt也就是access_token中放入用户信息,先不管为何user_info会以明文出现在这里,我们先看下access_token中多了哪些内容

POST请求hhttp://127.0.0.1:30000/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX2luZm8iOnsidXNlcm5hbWUiOiJ6aGFuZ3NhbiIsImlkIjoxLCJlbWFpbCI6IjEyMzQ1NkBmb3htYWlsLmNvbSIsIm1vYmlsZSI6IjEyMzQ1Njc4OTEyIiwiZnVsbG5hbWUiOiLlvKDkuIkifSwidXNlcl9uYW1lIjoiemhhbmdzYW4iLCJzY29wZSI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIiwiUk9MRV9BUEkiXSwiZXhwIjoxNjEwNjM4NjQzLCJhdXRob3JpdGllcyI6WyJwMSIsInAyIl0sImp0aSI6IjFkOGY3OGFmLTg1N2EtNGUzMS05ODYxLTZkYWJjNjU4NzcyNiIsImNsaWVudF9pZCI6ImMxIn0.Y9f5psNCgZi_I2KY3PLBLjuK5-U1VhXIB1vjKjMb9fc,得到相应结果

{
"aud": [
"res1"
],
"user_info": {
"username": "zhangsan",
"id": 1,
"email": "123456@foxmail.com",
"mobile": "12345678912",
"fullname": "张三"
},
"user_name": "zhangsan",
"scope": [
"ROLE_ADMIN",
"ROLE_USER",
"ROLE_API"
],
"exp": 1610638643,
"authorities": [
"p1",
"p2"
],
"jti": "1d8f78af-857a-4e31-9861-6dabc6587726",
"client_id": "c1"
}

可以看到user_info也已经填充到了jwt串中,那么为什么这个串还会以明文的形式出现在相应结果的其它字段中呢?还记得本文章中说过的一句话"可以看到,该字段是专门用来扩展OAuth字段的属性,万万没想到JWT同时用它扩展jwt串",我们给OAuth2AccessToken对象填充了AdditionalInformation字段,而这本来是为了扩展OAuth用的,所以返回结果中自然会出现这个字段。

到此为止,接口测试已经成功了,接下来修改网关和目标服务(这里是资源服务),将用户信息提取出来并保存到上下文中

二、修改网关

网关其实不需要做啥大的修改,但是会出现中文乱码问题,这里使用Base64编码之后再将用户数据放到请求头带给目标服务。修改TokenFilter类

//builder.header("token-info", payLoad).build();
builder.header("token-info", Base64.encode(payLoad.getBytes(StandardCharsets.UTF_8))).build();

三、修改资源服务

1.修改AuthFilterCustom

上一篇文章中床架了该类并将userName填充到了UsernamePasswordAuthenticationToken对象的Principal,这里我们需要将扩展的UserInfo整个填充到Principal,完整代码如下

public class AuthFilterCustom extends OncePerRequestFilter {

    @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
ObjectMapper objectMapper = new ObjectMapper();
String base64Token = request.getHeader("token-info");
if(StringUtils.isEmpty(base64Token)){
log.info("未找到token信息");
filterChain.doFilter(request,response);
return;
}
byte[] decode = Base64.decode(base64Token);
String tokenInfo = new String(decode, StandardCharsets.UTF_8);
JwtTokenInfo jwtTokenInfo = objectMapper.readValue(tokenInfo, JwtTokenInfo.class);
List<String> authorities1 = jwtTokenInfo.getAuthorities();
String[] authorities=new String[authorities1.size()];
authorities1.toArray(authorities);
//将用户信息和权限填充 到用户身份token对象中
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
jwtTokenInfo.getUser_info(),
null,
AuthorityUtils.createAuthorityList(authorities)
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
//将authenticationToken填充到安全上下文
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request,response);
}
}

这里JwtTokenInfo新增了user_info字段,而其类型正是前面说的UserDetailsExpand类型。

通过上述修改,我们可以在Controller中使用如下代码获取到上下文中的信息

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
UserDetailsExpand principal = (UserDetailsExpand)authentication.getPrincipal();

经过测试,结果良好,但是还存在问题,那就是在异步情况下,比如使用线程池或者新开线程的情况下,极有可能出现线程池内缓存或者取不到数据的情况(未测试,瞎猜的),具体可以参考我以前的文章使用 transmittable-thread-local 组件解决 ThreadLocal 父子线程数据传递问题

2.解决线程安全性问题

这一步是选做,但是还是建议做,如果不考虑线程安全性问题,上一步就可以了。

首先新增AuthContextHolder类维护我们需要的ThreadLocal,这里一定要使用TransmittableThreadLocal。

public class AuthContextHolder {
private TransmittableThreadLocal threadLocal = new TransmittableThreadLocal();
private static final AuthContextHolder instance = new AuthContextHolder(); private AuthContextHolder() {
} public static AuthContextHolder getInstance() {
return instance;
} public void setContext(UserDetailsExpand t) {
this.threadLocal.set(t);
} public UserDetailsExpand getContext() {
return (UserDetailsExpand)this.threadLocal.get();
} public void clear() {
this.threadLocal.remove();
}
}

然后新建拦截器AuthContextIntercepter

@Component
public class AuthContextIntercepter implements HandlerInterceptor { @Autowired
private ObjectMapper objectMapper; @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(Objects.isNull(authentication) || Objects.isNull(authentication.getPrincipal())){
//无上下文信息,直接放行
return true;
}
UserDetailsExpand principal = (UserDetailsExpand) authentication.getPrincipal();
AuthContextHolder.getInstance().setContext(principal);
return true;
} @Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
AuthContextHolder.getInstance().clear();
}
}

该拦截器在AuthFilter之后执行的,所以一定能获取到SecurityContextHolder中的内容,之后,我们就可以在Controller中使用如下代码获取用户信息了

UserDetailsExpand context = AuthContextHolder.getInstance().getContext();

是不是简单了很多~

3.其他问题

如果走到了上一步,则一定要使用阿里巴巴配套的TransmittableThreadLocal解决方案,否则TransmittableThreadLocal和普通的ThreadLocal没什么区别。具体参考使用 transmittable-thread-local 组件解决 ThreadLocal 父子线程数据传递问题

四、源代码

源码地址:https://gitee.com/kdyzm/spring-security-oauth-study/tree/v6.0.0

我的博客原文章地址:https://blog.kdyzm.cn/post/31

Spring Security OAuth2.0认证授权五:用户信息扩展到jwt的更多相关文章

  1. Spring Security OAuth2.0认证授权六:前后端分离下的登录授权

    历史文章 Spring Security OAuth2.0认证授权一:框架搭建和认证测试 Spring Security OAuth2.0认证授权二:搭建资源服务 Spring Security OA ...

  2. Spring Security OAuth2.0认证授权四:分布式系统认证授权

    Spring Security OAuth2.0认证授权系列文章 Spring Security OAuth2.0认证授权一:框架搭建和认证测试 Spring Security OAuth2.0认证授 ...

  3. Spring Security OAuth2.0认证授权三:使用JWT令牌

    Spring Security OAuth2.0系列文章: Spring Security OAuth2.0认证授权一:框架搭建和认证测试 Spring Security OAuth2.0认证授权二: ...

  4. Spring Security OAuth2.0认证授权二:搭建资源服务

    在上一篇文章[Spring Security OAuth2.0认证授权一:框架搭建和认证测试](https://www.cnblogs.com/kuangdaoyizhimei/p/14250374. ...

  5. Spring Security OAuth2.0认证授权一:框架搭建和认证测试

    一.OAuth2.0介绍 OAuth(开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不 需要将用户名和密码提供给第三方应用或分享他们数据的所有内容. 1.s ...

  6. Spring security OAuth2.0认证授权学习第四天(SpringBoot集成)

    基础的授权其实只有两行代码就不单独写一个篇章了; 这两行就是上一章demo的权限判断; 集成SpringBoot SpringBoot介绍 这个篇章主要是讲SpringSecurity的,Spring ...

  7. Spring security OAuth2.0认证授权学习第三天(认证流程)

    本来之前打算把第三天写基于Session认证授权的,但是后来视屏看完后感觉意义不大,而且内容简单,就不单独写成文章了; 简单说一下吧,就是通过Servlet的SessionApi 通过实现拦截器的前置 ...

  8. Spring security OAuth2.0认证授权学习第二天(基础概念-授权的数据模型)

    如何进行授权即如何对用户访问资源进行控制,首先需要学习授权相关的数据模型. 授权可简单理解为Who对What(which)进行How操作,包括如下: Who,即主体(Subject),主体一般是指用户 ...

  9. Spring security OAuth2.0认证授权学习第一天(基础概念-认证授权会话)

    这段时间没有学习,可能是因为最近工作比较忙,每天回来都晚上11点多了,但是还是要学习的,进过和我的领导确认,在当前公司的技术架构方面,将持续使用Spring security,暂不做Shiro的考虑, ...

随机推荐

  1. STL——容器(deque) 构造 & 头尾添加删除元素

    1.deque容器概念 deque是"double-ended queue"的缩写,和vector一样都是STL的容器,唯一不同的是:deque是双端数组,而vector是单端的. ...

  2. 云小课 | 需求任务还未分解,该咋整!项目管理Scrum项目工作分解的心酸谁能知?

    温馨提醒:本文约3000字,需要阅读5分钟,共分为8个部分,建议分段阅读! 软件开发过程中,从产品概念形成到产品规划.往往要做详细的需求分析和项目规划等,因此,选对一款项目管理工具对开发者就显得尤为重 ...

  3. 使用MySQL Shell创建MGR

    本篇知识点: 配置MGR所需的参数 使用MySQL Shell配置MGR shell.connect() var 设定临时变量 dba.createCluster() dba.getCluster() ...

  4. Day5 - 02 定义函数

    定义函数    Python中定义函数要使用def语句.     依次写出函数名.括号.括号中的参数和冒号,在缩进块中编写函数体,通过return语句返回函数返回值.如:                ...

  5. 如何正确运用break、continue和return

    break.continue和return 一.break 作用:跳出(离break语句最近的)循环 栗子: for(int i = 1;i <= 100;i++){ System.out.pr ...

  6. .net下com调用支持x86/x64

    起因 项目涉及u3d/wpf端的渲染图形合成,采用了开源项目spout,为了便捷,采用了spout的com版本作为c#端的调用 项目调整后,细节已经捋清楚了. 但是考虑桌面应用采用anypc,根据运行 ...

  7. .Net Core的简单单元测试基于Mock和自定义

    首先创建 使用mock 外部依赖一般用Mock 模拟 下载包 例如 3.1:首先先要使用MOCk来模拟测试方法需要的参数,这一步为 Arrange; 简单的模拟 var mock = new Mock ...

  8. python初学者-判断今天是今年的第几天代码

    判断今天是今年的第几天源代码 import time date =time.localtime() year,month,day=date[:3] day_month=[31,28,31,30,31, ...

  9. Redis不仅仅是缓存,还是……

    你需要一个经典数据库吗? 一段时间以来,巨大数量的数据处理迫使所有的应用程序在数据库层前添加缓存策略.即使经典数据库进行了大量的下划线优化,仍然不能提供足够的速度和可用性.主要原因在于数据存储越远,获 ...

  10. Astra示例程序库正式上线啦

    新上线的Astra示例程序库提供了基于多种编程语言和框架使用Astra的例子.借助这个示例程序库,你可以在短时间内建构起数据库.创建多个表.装载示例数据并部署基于Cassandra的应用程序. 什么是 ...