SpringCould整合spring-security+oauth2(亲测)
1.OAuth2 概念
-
OAuth2 其实是一个关于授权的网络标准,它制定了设计思路和运行流程,利用这个标准我们其实是可以自己实现 OAuth2 的认证过程的。
OAuth 2 有四种授权模式:
-
授权码模式(authorization code)
-
简化模式(implicit)
-
密码模式(resource owner password credentials)
-
客户端模式(client credentials)
具体 OAuth2 是什么,可以参考这篇文章。(http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html)
2.什么情况下需要用 OAuth2
例子:
首先大家最熟悉的就是几乎每个人都用过的,比如用微信登录、用 QQ 登录、用微博登录、用 Google 账号登录、用 github 授权登录等等,这些都是典型的 OAuth2 使用场景。假设我们做了一个自己的服务平台,如果不使用 OAuth2 登录方式,那么我们需要用户先完成注册,然后用注册号的账号密码或者用手机验证码登录。而使用了 OAuth2 之后,相信很多人使用过、甚至开发过公众号网页服务、小程序,当我们进入网页、小程序界面,第一次使用就无需注册,直接使用微信授权登录即可,大大提高了使用效率。因为每个人都有微信号,有了微信就可以马上使用第三方服务,这体验不要太好了。而对于我们的服务来说,我们也不需要存储用户的密码,只要存储认证平台返回的唯一ID 和用户信息即可。
以上是使用了 OAuth2 的授权码模式,利用第三方的权威平台实现用户身份的认证。当然了,如果你的公司内部有很多个服务,可以专门提取出一个认证中心,这个认证中心就充当上面所说的权威认证平台的角色,所有的服务都要到这个认证中心做认证
这样一说,发现没,这其实就是个单点登录的功能。这就是另外一种使用场景,对于多服务的平台,可以使用 OAuth2 实现服务的单点登录,只做一次登录,就可以在多个服务中自由穿行,当然仅限于授权范围内的服务和接口。
3.具体使用
OAuth2 其实是一个关于授权的网络标准,它制定了设计思路和运行流程,利用这个标准我们其实是可以自己实现 OAuth2 的认证过程的。今天要介绍的 spring-cloud-starter-oauth2 ,其实是 Spring Cloud 按照 OAuth2 的标准并结合 spring-security 封装好的一个具体实现。
3.1 系统架构说明
-
认证服务:OAuth2 主要实现端,Token 的生成、刷新、验证都在认证中心完成。
-
后台服务: 接收到请求后会到认证中心验证
-
前端:认证服务、后台服务之间的联调
上图描述了使用了 前端与OAuth2 认证服务、微服务间的请求过程。大致的过程就是前端用用户名和密码到后台服务登录,成功后后台服务到认证服务端换取 token,返回给前端,前端拿着 token 去各个微服务请求数据接口,一般这个 token 是放到 header 中的。当微服务接到请求后,先要拿着 token 去认证服务端检查 token 的合法性,如果合法,再根据用户所属的角色及具有的权限动态的返回数据
3.2 创建并配置认证服务端
配置最多的就是认证服务端,验证账号、密码,存储 token,检查 token ,刷新 token 等都是认证服务端的工作。
3.2.1 引入需要的maven包
<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
之所以引入 redis 包,是因为下面会介绍一种用 redis 存储 token 的方式。
3.2.2 配置好 application.yml
spring:
application:
name: auth-server
redis:
database: 2
host: localhost
port: 6379
server:
port: 6001
management:
endpoint:
health:
enabled: true
3.2.3 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.authorizeRequests()
.antMatchers("/**").permitAll();
}
}
使用@EnableWebSecurity
注解修饰,并继承自WebSecurityConfigurerAdapter
类。
这个类的重点就是声明 PasswordEncoder
和 AuthenticationManager
两个 Bean。稍后会用到。其中 BCryptPasswordEncoder
是一个密码加密工具类,它可以实现不可逆的加密,AuthenticationManager
是为了实现 OAuth2 的 password 模式必须要指定的授权管理 Bean。
3.2.4 实现 UserDetailsService
UserDetailsService
的核心就是 loadUserByUsername
方法,它要接收一个字符串参数,也就是传过来的用户名,返回一个 UserDetails
对象。
3.2.5 OAuth2 配置文件
创建一个配置文件继承自 AuthorizationServerConfigurerAdapter
有三个 configure 方法的重写。
AuthorizationServerEndpointsConfigurer
参数的重写
endpoints.authenticationManager(authenticationManager)
.userDetailsService(kiteUserDetailsService)
.tokenStore(redisTokenStore);
authenticationManage()
调用此方法才能支持 password 模式。
userDetailsService()
设置用户验证服务。
tokenStore()
指定 token 的存储方式。
redisTokenStore Bean 的定义如下:
@Configuration
public class RedisTokenStoreConfig {
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Bean
public TokenStore redisTokenStore (){
return new RedisTokenStore(redisConnectionFactory);
}
}
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 中。(优化认证服务有实例)
3.3.6 创建数据库SpringCloud、user表、实体User、UserRepository
实体bean
jpa接口
public interface UserRepository extends JpaRepository<User, String> {
user表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for user
-- ----------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`oauth_password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
`role` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of user
-- ----------------------------
INSERT INTO `user` VALUES ('1', 'admin', '$2a$10$D3PEtxvJ.N9Ko6osFaO4SO/jYcC8v7RHP34gZNk5THMvX7H5g8/NS', 'ROLE_ADMIN');
INSERT INTO `user` VALUES ('2', 'Custon', '$2a$10$D3PEtxvJ.N9Ko6osFaO4SO/jYcC8v7RHP34gZNk5THMvX7H5g8/NS', 'ROLE_ADMIN');
SET FOREIGN_KEY_CHECKS = 1;
3.2.6 启动认证服务
完成之后,启动项目,如果你用的是 IDEA 会在下方的 Mapping 窗口中看到 oauth2 相关的 RESTful 接口。
主要有如下几个:
POST /oauth/authorize 授权码模式认证授权接口
GET/POST /oauth/token 获取 token 的接口
POST /oauth/check_token 检查 token 合法性接口
3.3 创建用户客户端项目
上面创建完成了认证服务端,下面开始创建一个客户端,对应到我们系统中的业务相关的微服务。我们假设这个微服务项目是管理用户相关数据的,所以叫做用户客户端。
3.3.1 引用相关的 maven 包
<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>
3.3.2 application.yml 配置文件
spring:
application:
name: client-user
redis:
database: 2
host: localhost
port: 6379
server:
port: 6101
servlet:
context-path: /client-user
security:
oauth2:
client:
client-id: user-client
client-secret: user-secret-8888
user-authorization-uri: http://localhost:6001/oauth/authorize
access-token-uri: http://localhost:6001/oauth/token
resource:
id: user-client
user-info-uri: user-info
authorization:
check-token-access: http://localhost:6001/oauth/check_token
上面是常规配置信息以及 redis 配置,重点是下面的 security 的配置,这里的配置稍有不注意就会出现 401 或者其他问题。
client-id、client-secret 要和认证服务中的配置一致,如果是使用 inMemory 还是 jdbc 方式。
user-authorization-uri 是授权码认证方式需要的,下一篇文章再说。
access-token-uri 是密码模式需要用到的获取 token 的接口。
authorization.check-token-access 也是关键信息,当此服务端接收到来自客户端端的请求后,需要拿着请求中的 token 到认证服务端做 token 验证,就是请求的这个接口.
3.3.3 资源配置文件
在 OAuth2 的概念里,所有的接口都被称为资源,接口的权限也就是资源的权限,所以 Spring Security OAuth2 中提供了关于资源的注解 @EnableResourceServer
,和 @EnableWebSecurity
的作用类似。
因为使用的是 redis 作为 token 的存储,所以需要特殊配置一下叫做 tokenService 的 Bean,通过这个 Bean 才能实现 token 的验证。
3.3.4 最后,添加一个 RESTful 接口
@Slf4j
@RestController
public class UserController {
@GetMapping(value = "get")
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public Object get(){
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.getName();
}
}
一个 RESTful 方法,只有当访问用户具有 ROLE_ADMIN 权限时才能访问,否则返回 401 未授权。
通过 Authentication 参数或者 SecurityContextHolder.getContext().getAuthentication()
可以拿到授权信息进行查看。
3.4 测试
3.4.1 获取token
http://localhost:6001/oauth/token?username=2&password=123456&grant_type=password&scope=all&client_id=user-client&client_secret=user-secret-8888
3.4.3 校验token
接口地址 http://localhost:6001/oauth/check_token?token=5f861834-9c6f-4424-af1d-df35fefddee3
正常返回结果:
{
"active": true,
"exp": 1597915851,
"user_name": "2",
"authorities": [
"ROLE_ADMIN"
],
"client_id": "user-client",
"scope": [
"all"
]
}
校验失败结果:
{
"error": "invalid_token",
"error_description": "Token was not recognised"
}
3.4.3 获取refresh_token
grant_type: refresh_token
refresh_token: 从获取token里面取出
3.4.2 客户端携带token访问接口
http://localhost:6101/client-user/get
返回结果: “2” (登录username)
token到了过期时间,再次访问,返回结果
{
"error": "invalid_token",
"error_description": "f7520be0-fb2c-4386-9ffc-e64977314b2f"
}
3.5 优化方案
3.5.1 认证服务OAuth2Config的configure(ClientDetailsServiceConfigurer clients) 换成数据库存储
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//添加客户端信息
//使用内存存储OAuth客服端信息
clients.inMemory()
// client_id 客户单ID
.withClient("order-client")
// client_secret 客户单秘钥
.secret(passwordEncoder.encode("order-secret-8888"))
// 该客户端允许的授权类型,不同的类型,则获取token的方式不一样
.authorizedGrantTypes("refresh_token", "authorization_code", "password")
// token 有效期
.accessTokenValiditySeconds(3600)
// 允许的授权范围
.scopes("all")
.and()
.withClient("user-client")
.secret(passwordEncoder.encode("user-secret-8888"))
.authorizedGrantTypes("refresh_token", "authorization_code", "password")
.accessTokenValiditySeconds(3600)
.scopes("all");
}
把OAuth2Config.java文件的configure(ClientDetailsServiceConfigurer clients)替换成下面的
@Autowired
private DataSource dataSource;
/**
* jdbc配置
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
JdbcClientDetailsServiceBuilder jcsb = clients.jdbc(dataSource);
jcsb.passwordEncoder(passwordEncoder);
}
在application.yml添加数据库连接
#数据库连接
datasource:
url: jdbc:mysql://localhost:3306/springcloud?serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root
password: 123456
-
在数据库中增加表,并插入数据
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)
);
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);
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
('order-client', '$2a$10$GoIOhjqFKVyrabUNcie8d.ADX.qZSxpYbO6YK4L2gsNzlCIxEUDlW', 'all',
'authorization_code,refresh_token,password', null, null, 3600, 36000, null, true);
注意: client_secret 字段不能直接是 secret 的原始值,需要经过加密。因为是用的 BCryptPasswordEncoder
,所以最终插入的值应该是经过 BCryptPasswordEncoder.encode()
之后的值。
3.6 JWT替换 redisToke
上面 token 的存储用的是 redis 的方案,Spring Security OAuth2 还提供了 jdbc 和 jwt 的支持,jdbc 的暂不考虑,现在来介绍用 JWT 的方式来实现 token 的存储。
用 JWT 的方式就不用把 token 再存储到服务端了,JWT 有自己特殊的加密方式,可以有效的防止数据被篡改,只要不把用户密码等关键信息放到 JWT 里就可以保证安全性。
3.6.1 认证服务端改造
3.6.1.1 添加 JwtConfig 配置类
JwtAccessTokenConverter
是为了做 JWT 数据转换,这样做是因为 JWT 有自身独特的数据格式。如果没有了解过 JWT ,可以搜索一下先了解一下。
3.6.1.2 更改 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(kiteUserDetailsService)
/**
* 支持 password 模式
*/
.authenticationManager(authenticationManager);
}
注入 JWT 相关的 Bean,然后修改 configure(final AuthorizationServerEndpointsConfigurer endpoints)
方法为 JWT 存储模式。
3.6.2 改造用户客户端
3.6.2.1 修改 application.yml 配置文件
security:
oauth2:
client:
client-id: user-client
client-secret: user-secret-8888
user-authorization-uri: http://localhost:6001/oauth/authorize
access-token-uri: http://localhost:6001/oauth/token
resource:
jwt:
key-uri: http://localhost:6001/oauth/token_key
key-value: dev
注意认证服务端 JwtAccessTokenConverter
设置的 SigningKey 要和配置文件中的 key-value 相同,不然会导致无法正常解码 JWT ,导致验证不通过。
3.6.2.2 ResourceServerConfig 类的配置
3.6.3 测试
跟上面一样(这里就不重复了)
3.6.4 增强 JWT
如果我想在 JWT 中加入额外的字段(比方说用户的其他信息)怎么办呢,当然可以。spring security oauth2 提供了 TokenEnhancer
增强器。其实不光 JWT ,RedisToken 的方式同样可以。
3.6.4.1 OAuthConfig 配置类修改
声明一个增强器
public class JWTokenEnhancer implements TokenEnhancer {
通过 oAuth2Authentication 可以拿到用户名等信息,通过这些我们可以在这里查询数据库或者缓存获取更多的信息,而这些信息都可以作为 JWT 扩展信息加入其中。
在JwtTokenConfig.java 注入增强器 TokenEnhancer
@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;
}
@Bean
public TokenEnhancer jwtTokenEnhancer() {
return new JWTokenEnhancer();
}
}
OAuthConfig.java 修改 configure(final AuthorizationServerEndpointsConfigurer endpoints)
方法
@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( kiteUserDetailsService )
/**
* 支持 password 模式
*/
.authenticationManager( authenticationManager )
.tokenEnhancer( enhancerChain )
.accessTokenConverter( jwtAccessTokenConverter );
}
3.6.4.2 测试
再次请求 token ,返回内容中多了个刚刚加入的 jwt-ext 字段
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTU3MTc0NTE3OCwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJhNDU1MWQ5ZS1iN2VkLTQ3NTktYjJmMS1mMGI5YjIxY2E0MmMiLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.5j4hNsVpktG2iKxNqR-q1rfcnhlyV3M6HUBx5cd6PiQ",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImE0NTUxZDllLWI3ZWQtNDc1OS1iMmYxLWYwYjliMjFjYTQyYyIsImV4cCI6MTU3MTc3NzU3OCwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJmNTI3ODJlOS0wOGRjLTQ2NGUtYmJhYy03OTMwNzYwYmZiZjciLCJjbGllbnRfaWQiOiJ1c2VyLWNsaWVudCJ9.UQMf140CG8U0eWh08nGlctpIye9iJ7p2i6NYHkGAwhY",
"expires_in": 3599,
"scope": "all",
"jwt-ext": "JWT 扩展信息",
"jti": "a4551d9e-b7ed-4759-b2f1-f0b9b21ca42c"
}
3.6.4 用户客户端解析 JWT 数据
我们如果在 JWT 中加入了额外信息,这些信息我们可能会用到,而在接收到 JWT 格式的 token 之后,用户客户端要把 JWT 解析出来。
引入 JWT 包
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
加一个 RESTful 接口,在其中解析 JWT
同样注意其中签名的设置要与认证服务端相同
测试
用上一步的 token 请求上面的接口
返回内容如下:
{
"user_name": "admin",
"jwt-ext": "JWT 扩展信息",
"scope": [
"all"
],
"exp": 1571745178,
"authorities": [
"ROLE_ADMIN"
],
"jti": "a4551d9e-b7ed-4759-b2f1-f0b9b21ca42c",
"client_id": "user-client"
}