目录
使用 OAuth2 实现认证
什么情况下需要用 OAuth2
实现统一认证功能
搭建认证服务端
创建用户客户端(密码模式)
测试认证功能
用 JWT 替换 redisToken
OAuth2 授权码模式
使用 OAuth2 实现认证
OAuth 2 有四种授权模式,分别是授权码模式(authorization code)、简化模式(implicit)、密码模式(resource owner password credentials)、客户端模式(client credentials),具体 OAuth2 是什么?
OAuth2 其实是一个关于授权的网络标准,它制定了设计思路和运行流程,利用这个标准我们其实是可以自己实现 OAuth2 的认证过程的。我要介绍的 spring-cloud-starter-oauth2 ,其实是 Spring Cloud 按照 OAuth2 的标准并结合 spring-security 封装好的一个具体实现。
下面将使用授权码模式和密码模式两种方式来实现用户认证和授权管理。
什么情况下需要用 OAuth2
首先大家最熟悉的就是几乎每个人都用过的,比如用微信登录、用 QQ 登录、用微博登录、用 Google 账号登录、用 github 授权登录等等,这些都是典型的 OAuth2 使用场景。假设我们做了一个自己的服务平台,如果不使用 OAuth2 登录方式,那么我们需要用户先完成注册,然后用注册号的账号密码或者用手机验证码登录。而使用了 OAuth2 之后,相信很多人使用过、甚至开发过公众号网页服务、小程序,当我们进入网页、小程序界面,第一次使用就无需注册,直接使用微信授权登录即可,大大提高了使用效率。因为每个人都有微信号,有了微信就可以马上使用第三方服务,这体验不要太好了。而对于我们的服务来说,我们也不需要存储用户的密码,只要存储认证平台返回的唯一 ID 和用户信息即可。
以上是使用了 OAuth2 的授权码模式,利用第三方的权威平台实现用户身份的认证。当然了,如果你的公司内部有很多个服务,可以专门提取出一个认证中心,这个认证中心就充当上面所说的权威认证平台的角色,所有的服务都要到这个认证中心做认证。
这样一说,发现没,这其实就是个单点登录的功能。这就是另外一种使用场景,对于多服务的平台,可以使用 OAuth2 实现服务的单点登录,只做一次登录,就可以在多个服务中自由穿行,当然仅限于授权范围内的服务和接口。
微服务减少了服务间的耦合,同时也在某些方面增加了系统的复杂度,比如说用户认证。以本项目为例,用户看到的就是一个 APP 或者一个 web 站点,实际上背后是由多个独立的服务构成的,比如用户服务、文章服务等。用户只要第一次输入用户名、密码完成登录后,一段时间内,都可以任意访问各个页面,比如文章列表页面、个人中心页面、我的收藏等页面。
我们可以想象一下,自然能够想到,在请求各个服务、各个接口的时候,一定携带着什么凭证,然后各个服务才知道请求接口的用户是哪个,不然肯定有问题,那其实这里面的凭证简单来说就是一个 Token,标识用户身份的 Token。
实现统一认证功能
系统架构说明
认证中心:blog-oauth2-auth-server,OAuth2 主要实现端,Token 的生成、刷新、验证都在认证中心完成。
用户服务:blog-oauth2-client-user-server,微服务之一,接收到请求后会到认证中心验证。
客户端:例如 APP 端、web 端等终端。
大致的过程就是客户端用用户名和密码到认证服务端换取 Token,返回给客户端,客户端拿着 Token 去各个微服务请求数据接口,一般这个 token 是放到 header 中的。当微服务接到请求后,先要拿着 Token 去认证服务端检查 Token 的合法性,如果合法,再根据用户所属的角色及具有的权限动态的返回数据。
配置最多的就是认证服务端,验证账号、密码,存储 Token,检查 Token ,刷新 Token 等都是认证服务端的工作。
搭建认证服务端
首先创建一个 module 模块 blog-oauth2-auth-server,然后在 pom.xml
中增加相关依赖,代码如下所示:
<!-- spring mvc,etc... -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- oauth2 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
spring-cloud-starter-oauth2
包含了spring-cloud-starter-security
,所以不用再单独引入了。之所以引入 redis 包,是因为下面会介绍一种用 redis 存储 token 的方式。
在application.yml
属性文件,增加下面的配置:
spring:
application:
name: blog-oauth2-auth-server # app 名称
redis:
host: 172.0.0.1
port: 6379
password: 123456
database: 0
timeout: 100ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
server:
port: 8100
management:
endpoint:
health:
enabled: true
创建 spring security 基础配置类
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 允许匿名访问所有接口 主要是 oauth 接口
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().and().formLogin();
http.authorizeRequests()
.antMatchers("/**").permitAll();
}
}
使用 @EnableWebSecurity
注解修饰,并继承自 WebSecurityConfigurerAdapter
。这个类的重点就是声明 PasswordEncoder
和 AuthenticationManager
两个 Bean。其中 BCryptPasswordEncoder
是一个密码加密工具类,它可以实现不可逆的加密,AuthenticationManager
是为了实现 OAuth2 的 password 模式必须要指定的授权管理 Bean。
实现 UserDetailsService
如果你之前用过 Security 的话,那肯定对这个类很熟悉,它是实现用户身份验证的一种方式,也是最简单方便的一种。另外还有结合 AuthenticationProvider
的方式。
UserDetailsService
的核心就是 loadUserByUsername
方法,它要接收一个字符串参数,也就是传过来的用户名,返回一个 UserDetails
对象。
@Component
public class BlogUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("username ---" + username);
// 查询数据库操作
if(!"admin".equals(username)){
throw new UsernameNotFoundException("the user is not found");
}else{
// 用户角色也应在数据库中获取
String role = "ROLE_ADMIN";
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
// 线上环境应该通过用户名查询数据库获取加密后的密码
String password = passwordEncoder.encode("123456");
return new org.springframework.security.core.userdetails.User(username,password, authorities);
}
}
}
这里为了做演示,把用户名、密码和所属角色都写在代码里了,正式环境中,这里应该是从数据库或者其他地方根据用户名将加密后的密码及所属角色查出来的。账号 admin ,密码 123456,稍后在换取 Token 的时候会用到。并且给这个用户设置 "ROLE_ADMIN" 角色。
创建 OAuth2 配置文件
创建一个配置文件继承自 AuthorizationServerConfigurerAdapter
。
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
public PasswordEncoder passwordEncoder;
@Autowired
public UserDetailsService blogUserDetailsService;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private TokenStore redisTokenStore;
@Override
public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
/**
* redis token 方式
*/
endpoints.authenticationManager(authenticationManager)
.userDetailsService(blogUserDetailsService)
.tokenStore(redisTokenStore);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("user-client")
.secret(passwordEncoder.encode("user-secret-8888"))
.authorizedGrantTypes("refresh_token", "authorization_code", "password")
.accessTokenValiditySeconds(3600)
.scopes("all");
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
security.checkTokenAccess("isAuthenticated()");
security.tokenKeyAccess("isAuthenticated()");
}
}
有三个 configure 方法的重写。
AuthorizationServerEndpointsConfigurer
参数的重写。
endpoints
// 调用此方法才能支持 password 模式
.authenticationManager(authenticationManager)
// 设置用户验证服务
.userDetailsService(blogUserDetailsService)
// 指定 token 的存储方式
.tokenStore(redisTokenStore);
redisTokenStore
的定义如下:
@Configuration
public class RedisTokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore redisTokenStore (){
return new RedisTokenStore(redisConnectionFactory);
}
}
ClientDetailsServiceConfigurer
参数的重写,在这里定义各个端的约束条件。包括
ClientId、Client-Secret:这两个参数对应请求端定义的 cleint-id 和 client-secret。
authorizedGrantTypes 可以包括如下几种设置中的一种或多种:
- authorization_code:授权码类型。
- implicit:隐式授权类型。
- password:资源所有者(即用户)密码类型。
- client_credentials:客户端凭据(客户端ID以及Key)类型。
- refresh_token:通过以上授权获得的刷新令牌来获取新的令牌。
accessTokenValiditySeconds:token 的有效期
scopes:用来限制客户端访问的权限,在换取的 token 的时候会带上 scope 参数,只有在 scopes 定义内的,才可以正常换取 token。
上面代码中是使用 inMemory 方式存储的,将配置保存到内存中,相当于硬编码了。正式环境下的做法是持久化到数据库中,比如 mysql 中,具体的做法如下。
在数据库中增加表,并插入数据:
create table oauth_client_details (
client_id VARCHAR(256) PRIMARY KEY,
resource_ids VARCHAR(256),
client_secret VARCHAR(256),
scope VARCHAR(256),
authorized_grant_types VARCHAR(256),
web_server_redirect_uri VARCHAR(256),
authorities VARCHAR(256),
access_token_validity INTEGER,
refresh_token_validity INTEGER,
additional_information VARCHAR(4096),
autoapprove VARCHAR(256)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;
INSERT INTO oauth_client_details
(client_id, client_secret, scope, authorized_grant_types,
web_server_redirect_uri, authorities, access_token_validity,
refresh_token_validity, additional_information, autoapprove)
VALUES
('user-client', '$2a$10$o2l5kA7z.Caekp72h5kU7uqdTDrlamLq.57M1F6ulJln9tRtOJufq', 'all',
'authorization_code,refresh_token,password', null, null, 3600, 36000, null, true);
注意: client_secret 字段不能直接是 secret 的原始值,需要经过加密。因为是用的
BCryptPasswordEncoder
,所以最终插入的值应该是经过BCryptPasswordEncoder.encode()
之后的值。
在配置文件 application.yml
中添加关于数据库的配置:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/blog_oauth2?characterEncoding=UTF-8&useSSL=false
username: root
password: root
hikari:
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
maximum-pool-size: 9
添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
OAuth2Config
重写 configure(ClientDetailsServiceConfigurer clients) 方法修改为如下:
@Autowired
private DataSource dataSource;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
JdbcClientDetailsServiceBuilder builder = clients.jdbc(dataSource);
builder.passwordEncoder(passwordEncoder);
}
还有一个重写的方法 public void configure(AuthorizationServerSecurityConfigurer security)
,这个方法限制客户端访问认证接口的权限。
// 允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401
security.allowFormAuthenticationForClients();
// 允许已授权用户访问 checkToken 接口
security.checkTokenAccess("isAuthenticated()");
// 获取 token 接口
security.tokenKeyAccess("isAuthenticated()");
完成之后,启动项目,如果你用的是 IDEA 会在下方的 Mapping 窗口中看到 oauth2 相关的 RESTful 接口。
主要有如下几个:
POST /oauth/authorize 授权码模式认证授权接口
GET/POST /oauth/token 获取 token 的接口
POST /oauth/check_token 检查 token 合法性接口
创建用户客户端(密码模式)
上面创建完成了认证服务端,下面开始创建一个客户端,对应到我们系统中的业务相关的微服务。我们假设这个微服务项目是管理用户相关数据的,所以叫做用户客户端。
创建一个 module 模块 blog-oauth2-client-user-server,然后在 pom.xml
中增加相关依赖,代码如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
在application.yml
属性文件,增加下面的配置:
spring:
application:
name: blog-oauth2-client-user-server # app 名称
redis:
host: 172.0.0.1
port: 6379
password: 123456
database: 0
timeout: 100ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
security:
oauth2:
client:
client-id: user-client
client-secret: user-secret-8888
user-authorization-uri: http://localhost:8100/oauth/authorize
access-token-uri: http://localhost:8100/oauth/token
resource:
id: user-client
user-info-uri: user-info
authorization:
check-token-access: http://localhost:8100/oauth/check_token
server:
port: 8101
上面是常规配置信息以及 redis 配置,重点是下面的 security 的配置,这里的配置稍有不注意就会出现 401 或者其他问题。
client-id、client-secret 要和认证服务中的配置一致,无论是使用 inMemory 还是 jdbc 方式。
user-authorization-uri 是授权码认证方式需要的。
access-token-uri 是密码模式需要用到的获取 Token 的接口。
authorization.check-token-access 也是关键信息,当此服务端接收到来自客户端端的请求后,需要拿着请求中的 token 到认证服务端做 Token 验证,就是请求的这个接口。
在 OAuth2 的概念里,所有的接口都被称为资源,接口的权限也就是资源的权限,所以 Spring Security OAuth2 中提供了关于资源的注解 @EnableResourceServer
,和 @EnableWebSecurity
的作用类似。
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Value("${spring.security.oauth2.client.client-id}")
private String clientId;
@Value("${spring.security.oauth2.client.client-secret}")
private String secret;
@Value("${spring.security.oauth2.authorization.check-token-access}")
private String checkTokenEndpointUrl;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore redisTokenStore (){
return new RedisTokenStore(redisConnectionFactory);
}
@Bean
public RemoteTokenServices tokenService() {
RemoteTokenServices tokenService = new RemoteTokenServices();
tokenService.setClientId(clientId);
tokenService.setClientSecret(secret);
tokenService.setCheckTokenEndpointUrl(checkTokenEndpointUrl);
return tokenService;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenServices(tokenService());
}
}
因为使用的是 redis 作为 token 的存储,所以需要特殊配置一下叫做 tokenService 的 Bean,通过这个 Bean 才能实现 Token 的验证。
最后,添加一个 RESTful 接口:
@RestController
@RequestMapping("/client-user")
public class UserClientController {
@GetMapping(value = "gettoken")
//@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public Object get(Authentication authentication){
//Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
authentication.getCredentials();
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();
String token = details.getTokenValue();
return token;
}
}
一个 RESTful 方法,只有当访问用户具有 ROLE_ADMIN 权限时才能访问,否则返回 401 未授权。
通过 Authentication 参数或者 SecurityContextHolder.getContext().getAuthentication()
可以拿到授权信息进行查看。
测试认证功能
启动认证服务端,启动端口为 8100。
启动用户服务客户端,启动端口为 8101。
请求认证服务端获取 token。
使用 postman 来做访问请求的,请求格式如下:
POST http://localhost:6001/oauth/token?grant_type=password&username=admin&password=123456&scope=all
Accept: */*
Cache-Control: no-cache
Authorization: Basic dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==
假设咱们在一个 web 端使用,grant_type 是 password,表明这是使用 OAuth2 的密码模式。
username=admin 和 password=123456 就相当于在 web 端登录界面输入的用户名和密码,我们在认证服务端配置中固定了用户名是 admin 、密码是 123456,而线上环境中则应该通过查询数据库获取。
scope=all 是权限有关的,在认证服务的 OAuthConfig 中指定了 scope 为 all 。
Authorization 要加在请求头中,格式为 Basic 空格 base64(clientId:clientSecret),这个微服务客户端的 client-id 是 user-client,client-secret 是 user-secret-8888,将这两个值通过冒号连接,并使用 base64 编码(user-client:user-secret-8888)之后的值为 dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==,可以通过 https://www.sojson.com/base64.html 在线编码获取。
运行请求后,如果参数都正确的话,获取到的返回内容如下,是一段 json 格式:
{
"access_token": "56ddb369-9552-41f6-99be-351c546b25ba",
"token_type": "bearer",
"refresh_token": "ddc90a1c-859e-4be0-8763-592ee093d6bf",
"expires_in": 3599,
"scope": "all"
}
- access_token : 就是之后请求需要带上的 Token,也是本次请求的主要目的。
- token_type:为 bearer,这是 access token 最常用的一种形式。
- refresh_token:之后可以用这个值来换取新的 Token,而不用输入账号密码。
- expires_in:Token 的过期时间(秒)。
我们在用户客户端中定义了一个接口 http://localhost:8101/client-user/gettoken,现在就拿着上一步获取的 token 来请求这个接口。
GET http://localhost:8101/client-user/gettoken
Accept: */*
Cache-Control: no-cache
Authorization: Bearer 56ddb369-9552-41f6-99be-351c546b25ba
一般都会设置 access_token 的过期时间小于 refresh_token 的过期时间,以便在 access_token 过期后,不用用户再次登录的情况下,获取新的 access_token。
### 换取 access_token
POST http://localhost:8100/oauth/token?grant_type=refresh_token&refresh_token=ddc90a1c-859e-4be0-8763-592ee093d6bf
Accept: */*
Cache-Control: no-cache
Authorization: Basic dXNlci1jbGllbnQ6dXNlci1zZWNyZXQtODg4OA==
grant_type 设置为 refresh_token。
refresh_token 设置为请求 token 时返回的 refresh_token 的值。
请求头加入 Authorization,格式依然是 Basic + 空格 + base64(client-id:client-secret)。
请求成功后会返回和请求 token 同样的数据格式。
用 JWT 替换 redisToken
上面 Token 的存储用的是 redis 的方案,Spring Security OAuth2 还提供了 jdbc 和 jwt 的支持,jdbc 的暂不考虑,现在来介绍用 JWT 的方式来实现 Token 的存储。
用 JWT 的方式就不用把 Token 再存储到服务端了,JWT 有自己特殊的加密方式,可以有效的防止数据被篡改,只要不把用户密码等关键信息放到 JWT 里就可以保证安全性。
改造认证服务端
先把有关 redis 的配置去掉,添加 JwtConfig 配置类。
@Configuration
public class JwtTokenConfig {
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
accessTokenConverter.setSigningKey("dev");
return accessTokenConverter;
}
}
JwtAccessTokenConverter
是为了做 JWT 数据转换,这样做是因为 JWT 有自身独特的数据格式。
更改 OAuthConfig 配置类
@Autowired
private TokenStore jwtTokenStore;
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Override
public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
/**
* 普通 jwt 模式
*/
endpoints.tokenStore(jwtTokenStore)
.accessTokenConverter(jwtAccessTokenConverter)
.userDetailsService(blogUserDetailsService)
.authenticationManager(authenticationManager);
}
注入 JWT 相关的 Bean,然后修改 configure(final AuthorizationServerEndpointsConfigurer endpoints)
方法为 JWT 存储模式。
改造用户客户端
修改 application.yml
配置文件:
spring:
security:
oauth2:
client:
client-id: user-client
client-secret: user-secret-8888
user-authorization-uri: http://localhost:8100/oauth/authorize
access-token-uri: http://localhost:8100/oauth/token
resource:
jwt:
key-uri: http://localhost:6001/oauth/token_key
key-value: dev
注意:认证服务端
JwtAccessTokenConverter
设置的SigningKey
要和配置文件中的 key-value 相同,不然会导致无法正常解码 JWT ,导致验证不通过。
修改 ResourceServerConfig
类的配置
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
accessTokenConverter.setSigningKey("dev");
accessTokenConverter.setVerifierKey("dev");
return accessTokenConverter;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(jwtTokenStore());
}
}
运行请求 token 接口的请求,返回结果如下:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTMyOTcxNzAsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJjOTk3NDVjOC01OGUzLTQ4MDUtYWFlZC1lNWU2MTNiYWYwOTYiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCIsInNjb3BlIjpbImFsbCJdfQ.oMqHNmdv_aLBSSe3e6eG3baQsAUIUVpXnDxjHMZzuwA",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiJjOTk3NDVjOC01OGUzLTQ4MDUtYWFlZC1lNWU2MTNiYWYwOTYiLCJleHAiOjE2MTMzMjk1NzAsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiYmI0OGI4YjktMmZhYi00M2MzLTg1ZTctZTJhYjQyNjYzZWQ1IiwiY2xpZW50X2lkIjoidXNlci1jbGllbnQifQ.IPfcxXugIhOh8a-g2QYlycnklBvA6faeU2Xyy5hK0qA",
"expires_in": 3596,
"scope": "all",
"jti": "c99745c8-58e3-4805-aaed-e5e613baf096"
}
我们已经看到返回的 Token 是 JWT 格式了,到 JWT 在线解码网站 https://jwt.io/ 或者 http://jwt.calebb.net/将 token 解码看一下。
解码结果
可以看出 user_name、client_id 等信息都在其中。
拿着返回的 token 请求用户客户端接口。
如果我想在 JWT 中加入额外的字段(比方说用户的其他信息)怎么办呢,当然可以。spring security oauth2 提供了 TokenEnhancer
增强器。其实不光 JWT ,RedisToken 的方式同样可以。
声明一个增强器:
public class JWTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
Map<String, Object> info = new HashMap<>();
info.put("jwt-ext", "JWT 扩展信息");
((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
return oAuth2AccessToken;
}
}
通过 oAuth2Authentication
可以拿到用户名等信息,通过这些我们可以在这里查询数据库或者缓存获取更多的信息,而这些信息都可以作为 JWT 扩展信息加入其中。
修改 OAuthConfig
配置类:
@Bean
public TokenEnhancer jwtTokenEnhancer(){
return new JWTokenEnhancer();
}
@Override
public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
/**
* jwt 增强模式
*/
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
List<TokenEnhancer> enhancerList = new ArrayList<>();
enhancerList.add(jwtTokenEnhancer());
enhancerList.add(jwtAccessTokenConverter);
enhancerChain.setTokenEnhancers(enhancerList);
endpoints.tokenStore(jwtTokenStore)
.userDetailsService(blogUserDetailsService)
.authenticationManager(authenticationManager)
.tokenEnhancer(enhancerChain)
.accessTokenConverter(jwtAccessTokenConverter);
}
再次请求 Token ,返回内容中多了个刚刚加入的 jwt-ext 字段:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMzI5ODQ0MywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiI3ODM3YTE2ZC1hMDA3LTRiZjEtODMzOS05NDMwMWU5MDcxN2MiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.gIR-5Bq6CT2okdAQnduYL3RPbcUKHky_U3L1m0h-Tx4",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6Ijc4MzdhMTZkLWEwMDctNGJmMS04MzM5LTk0MzAxZTkwNzE3YyIsImV4cCI6MTYxMzMzMDg0MywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiIzNjJmY2Y4OC1lMjllLTQwZjAtODFkNC0xYjhlZGNkM2Y1N2QiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.IvTYwKrkNJeWDLy8F3B1KxGGDUQW3VPNQ439g-HYC0Y",
"expires_in": 3597,
"scope": "all",
"jwt-ext": "JWT 扩展信息",
"jti": "7837a16d-a007-4bf1-8339-94301e90717c"
}
我们如果在 JWT 中加入了额外信息,这些信息我们可能会用到,而在接收到 JWT 格式的 token 之后,用户客户端要把 JWT 解析出来。
加一个 RESTful 接口,在其中解析 JWT:
@GetMapping(value = "jwtdecode")
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public Object jwtParser(Authentication authentication) {
OAuth2AuthenticationDetails details =
(OAuth2AuthenticationDetails) authentication.getDetails();
String jwtToken = details.getTokenValue();
Jwt jwt = JwtHelper.decode(jwtToken);
return jwt.getClaims();
}
同样注意其中签名的设置要与认证服务端相同。
用上一步的 Token 请求上面的接口:
### 解析 jwt
GET http://localhost:6101/client-user/jwtdecode
Accept: */*
Cache-Control: no-cache
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMzI5ODQ0MywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiI3ODM3YTE2ZC1hMDA3LTRiZjEtODMzOS05NDMwMWU5MDcxN2MiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.gIR-5Bq6CT2okdAQnduYL3RPbcUKHky_U3L1m0h-Tx4
OAuth2 授权码模式
授权码模式的认证过程是这样的:
- 用户客户端请求认证服务器的认证接口,并附上回调地址;
- 认证服务接口接收到认证请求后调整到自身的登录界面;
- 用户输入用户名和密码,点击确认,跳转到授权、拒绝提示页面(也可省略);
- 用户点击授权或者默认授权后,跳转到微服务客户端的回调地址,并传入参数 code;
- 回调地址一般是一个 RESTful 接口,此接口拿到 code 参数后,再次请求认证服务器的 token 获取接口,用来换取 access_token 等信息。
获取到 access_token 后,拿着 token 去请求各个微服务客户端的接口。
注意:上面所说的用户客户端可以理解为浏览器、app 端,微服务客户端就是我们系统中的例如订单服务、用户服务等微服务,认证服务端就是用来做认证授权的服务,相对于认证服务端来说,各个业务微服务也可以称作是它的客户端。
认证服务端继续用之前的配置,代码不需要任何改变,只需要在数据库里加一条记录,来支持新加的微服务客户端的认证。
我们要创建的客户端的 client-id 为 code-client,client-secret 为 code-secret-8888,但是同样需要加密,可以用如下代码获取:
System.out.println(new BCryptPasswordEncoder().encode("code-secret-8888"));
除了以上这两个参数,要将 authorized_grant_types 设置为 authorization_code,refresh_token,web_server_redirect_uri 设置为回调地址,稍后微服务客户端会创建这个接口。
然后将这条记录组织好插入数据库中。
INSERT INTO oauth_client_details
(client_id, client_secret, scope, authorized_grant_types,
web_server_redirect_uri, authorities, access_token_validity,
refresh_token_validity, additional_information, autoapprove)
VALUES
('code-client', '$2a$10$jENDQZRtqqdr6sXGQK.L0OBADGIpyhtaRfaRDTeLKI76I/Ir1FDn6', 'all',
'authorization_code,refresh_token', 'http://localhost:8102/client-authcode/login', null, 3600, 36000, null, true);
创建一个 module 模块 blog-oauth2-client-user-code-server,然后在 pom.xml
中增加相关依赖,代码如下所示:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
引入 okhttp 和 thymeleaf 是因为要做一个简单的页面并模拟正常的认证过程。
配置文件 application.yml
:
server:
port: 8102
servlet:
context-path: /client-authcode
spring:
application:
name: blog-oauth2-client-user-code-server # app 名称
security:
oauth2:
client:
client-id: code-client
client-secret: code-secret-8888
user-authorization-uri: http://localhost:8100/oauth/authorize
access-token-uri: http://localhost:8100/oauth/token
resource:
jwt:
key-uri: http://localhost:8100/oauth/token_key
key-value: dev
id: code-client
user-info-uri: user-info
authorization:
check-token-access: http://localhost:8100/oauth/check_token
redis:
host: 172.0.0.1
port: 6379
password: 123456
database: 0
timeout: 100ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
创建 ResourceServerConfig
:
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
accessTokenConverter.setSigningKey("dev");
accessTokenConverter.setVerifierKey("dev");
return accessTokenConverter;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(jwtTokenStore());
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/login").permitAll();
}
}
使用 jwt 作为 Token 的存储,注意允许 /login
接口无授权访问,这个地址是认证的回调地址,会返回 code 参数。
到现在我们把认证服务端和刚刚创建的认证客户端启动起来,就可以手工测试一下了。回调接口不是还没创建呢吗,没关系,我们权当那个地址现在就是为了接收 code 参数的。
在浏览器访问 /oauth/authorize
授权接口,接口地址为:
http://localhost:8100/oauth/authorize?client_id=code-client&response_type=code&redirect_uri=http://localhost:8102/client-authcode/login
注意 response_type 参数设置为 code,redirect_uri 设置为数据库中插入的回调地址。
输入上面地址后,会自动跳转到认证服务端的登录页面,输入用户名、密码,这里用户名是 admin
,密码是 123456
。
登录页面
点击确定后,来到授权确认页面,页面上有 Authorize 和 Deny (授权和拒绝)两个按钮。可通过将 autoapprove 字段设置为 0 来取消此页面的展示,默认直接同意授权。
授权确认页面
点击同意授权后,跳转到了回调地址,虽然是 404 ,但是我们只是为了拿到 code 参数,注意地址后面的 code 参数。
拿到这个 code 参数是为了向认证服务器 /oauth/token
接口请求 access_token ,使用 postman 等工具测试。
注意: grant_type 参数设置为 authorization_code,code 就是上一步回调地址中加上的,redirect_uri 仍然要带上,回作为验证条件,如果不带或者与前面设置的不一致,会出现错误。
请求头 Authorization ,仍然是 Basic + 空格 + base64(client_id:client_secret),可以通过 https://www.sojson.com/base64.html 网站在线做 base64 编码。
code-client:code-secret-8888 通过 base64 编码后结果为 Y29kZS1jbGllbnQ6Y29kZS1zZWNyZXQtODg4OA==
。
POST http://localhost:8100/oauth/token?grant_type=authorization_code&client=code-client&code=ABtcE3&redirect_uri=http://localhost:8102/client-authcode/login
Accept: */*
Cache-Control: no-cache
Authorization: Basic Y29kZS1jbGllbnQ6Y29kZS1zZWNyZXQtODg4OA==
发送请求后,返回的 json 内容如下:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMzM5MzAyNiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJkMjQ5ZDcwYy1jNjE2LTQ3Y2ItOGZhOC1mN2M3YTMwZGU3YTciLCJjbGllbnRfaWQiOiJjb2RlLWNsaWVudCJ9.5NUx_rXkNg5eF0ZM9DCp6bEaIBlw3VVxhw_3ExmHQLg",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImQyNDlkNzBjLWM2MTYtNDdjYi04ZmE4LWY3YzdhMzBkZTdhNyIsImV4cCI6MTYxMzQyNTQyNiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJlYzdmNTA2Yy04MjY4LTQ1NjUtOGU4OC1kOTU1MDNmNGVlNmUiLCJjbGllbnRfaWQiOiJjb2RlLWNsaWVudCJ9.nFvlCXhZyKwCTFVK4k18FUCvs7YN3_Ur8Lj6fm8HLEU",
"expires_in": 3597,
"scope": "all",
"jwt-ext": "JWT 扩展信息",
"jti": "d249d70c-c616-47cb-8fa8-f7c7a30de7a7"
}
和 password 模式拿到的 Token 内容是一致的,接下来的请求都需要带上 access_token 。
接口内容如下:
@RestController
public class UserClientController {
@GetMapping(value = "gettoken")
//@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public Object get(Authentication authentication){
//Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
authentication.getCredentials();
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)authentication.getDetails();
String token = details.getTokenValue();
return token;
}
}
经过以上的手工测试,证明此过程是通的,但是还没有达到自动化。如果你集成过微信登录,那你一定知道我们在回调地址中做了什么,拿到返回的 code 参数去 Token 接口换取 access_token 对不对,没错,思路都是一样的,我们的回调接口中同样要拿 code 去换取 access_token。
为此,我做了一个简单的页面,并且在回调接口中请求获取 Token 的接口。
在 resources 目录下创建 templates 目录,用来存放 thymeleaf 的模板,不做样式,只做最简单的演示,创建 index.html 模板,内容如下:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Blog-OAuth2 Client</title>
</head>
<body>
<div>
<a href="http://localhost:8100/oauth/authorize?client_id=code-client&response_type=code&redirect_uri=http://localhost:8102/client-authcode/login">登录</a>
<span th:text="'当前认证用户:' + ${username}"></span>
<span th:text="${accessToken}"></span>
</div>
</body>
</html>
回调接口及其他接口:
@Controller
public class UserClientController {
/**
* 用来展示index.html 模板
* @return
*/
@GetMapping(value = "index")
public String index(){
return "index";
}
@GetMapping(value = "login")
public Object login(String code, Model model) {
String tokenUrl = "http://localhost:8100/oauth/token";
OkHttpClient httpClient = new OkHttpClient();
RequestBody body = new FormBody.Builder()
.add("grant_type", "authorization_code")
.add("client", "code-client")
.add("redirect_uri","http://localhost:8102/client-authcode/login")
.add("code", code)
.build();
Request request = new Request.Builder()
.url(tokenUrl)
.post(body)
.addHeader("Authorization", "Basic Y29kZS1jbGllbnQ6Y29kZS1zZWNyZXQtODg4OA==")
.build();
try {
Response response = httpClient.newCall(request).execute();
String result = response.body().string();
ObjectMapper objectMapper = new ObjectMapper();
Map tokenMap = objectMapper.readValue(result, Map.class);
String accessToken = tokenMap.get("access_token").toString();
String claims = JwtHelper.decode(accessToken).getClaims();
Map claimsMap = objectMapper.readValue(claims, Map.class);
model.addAttribute("username", claimsMap.get("user_name"));
model.addAttribute("accessToken", accessToken);
return "index";
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
其中 index() 方法是为了展示 thymeleaf 模板,login 方法就是回调接口,这里用了 okhttp3 用作接口请求,请求认证服务端的 /oauth/token
接口来换取 access_token,只是把我们手工测试的步骤自动化了。
我们假设 index.html 这个页面就是一个网站的首页,未登录的用户会在网站上看到登录按钮,我们访问这个页面:http://localhost:8102/client-authcode/index,看到的页面是这样的。
网站首页
接下来,点击登录按钮,通过上面的模板代码看出,点击后其实就是跳转到了我们手工测试第一步访问的那个地址,之后的操作和上面手工测试的是一致的,输入用户名密码、点击同意授权。
接下来,页面跳转回回调地址<http://localhost:8102/client-authcode/login?code=xxx 的时候,login 方法拿到 code 参数,开始构造 post 请求体,并把 Authorization 加入请求头,然后请求 oauth/token
接口,最后将拿到的 token 和 通过 token 解析后的 username 返回给前端,最后呈现的效果如下:
认证后首页
最后,拿到 token 后的客户端,就可以将 token 加入到请求头后,去访问需要授权的接口了。