文章较长,建议慢慢看,建议收藏
本文简述
OAuth(开放授权)是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方应用或分享他们数据的所有内容
OAuth 2 有四种授权模式,分别是授权码模式(authorization code)、简化模式(implicit)、密码模式(resource owner password credentials)、客户端模式(client credentials)
本文我们将使用授权码模式和密码模式两种方式来实现用户认证和授权管理,共两篇文章
OAuth基本使用
首先大家最熟悉的就是几乎每个人都用过的,比如用微信登录、用 QQ 登录、用微博登录、用 Google 账号登录、用 github 授权登录等等,这些都是典型的 OAuth2 使用场景。假设我们做了一个自己的服务平台,如果不使用 OAuth2 登录方式,那么我们需要用户先完成注册,然后用注册号的账号密码或者用手机验证码登录。而使用了 OAuth2 之后,相信很多人使用过、甚至开发过公众号网页服务、小程序,当我们进入网页、小程序界面,第一次使用就无需注册,直接使用微信授权登录即可,大大提高了使用效率。因为每个人都有微信号,有了微信就可以马上使用第三方服务,这体验不要太好了。而对于我们的服务来说,我们也不需要存储用户的密码,只要存储认证平台返回的唯一ID 和用户信息即可
以上是使用了 OAuth2 的授权码模式,利用第三方的权威平台实现用户身份的认证。当然了,如果你的公司内部有很多个服务,可以专门提取出一个认证中心,这个认证中心就充当上面所说的权威认证平台的角色,所有的服务都要到这个认证中心做认证。
这样一说,发现没,这其实就是个单点登录的功能。这就是另外一种使用场景,对于多服务的平台,可以使用 OAuth2 实现服务的单点登录,只做一次登录,就可以在多个服务中自由穿行,当然仅限于授权范围内的服务和接口。
实现统一认证服务
本篇先介绍密码模式(resource owner password credentials),下一篇介绍授权码模式
微服务横行的今天,一般的公司都会使用微服务进行开发,微服务很好的对服务之间解耦,同时也在某些方面增加了系统的复杂度,比如说用户认证。假设我们这里实现了一个电商平台,用户看到的就是一个 APP 或者一个 web 站点,实际上背后是由多个独立的服务构成的,比如用户服务、订单服务、产品服务等。用户只要第一次输入用户名、密码完成登录后,一段时间内,都可以任意访问各个页面,比如产品列表页面、我的订单页面、我的关注等页面
我们可以想象一下,自然能够想到,在请求各个服务、各个接口的时候,一定携带着什么凭证,然后各个服务才知道请求接口的用户是哪个,不然肯定有问题,那其实这里面的凭证简单来说就是一个 Token,标识用户身份的 Token
举个例子来对这个流程进行说明:
认证中心:oauth2-auth-server,OAuth2 主要实现端,Token 的生成、刷新、验证都在认证中心完成。
订单服务:oauth2-client-order-server,微服务之一,接收到请求后会到认证中心验证。
用户服务:oauth2-client-user-server,微服务之二,接收到请求后会到认证中心验证。
客户端:例如 APP 端、web 端 等终端
大致的流程是:客户端去认证中心申请访问凭证token,然后认证中心对于客户端请求来的帐号密码进行验证,如果验证通过,则颁发token,返回给客户端,客户端拿着 token 去各个微服务请求数据接口,一般这个 token 是放到 header 中的。当微服务接到请求后,先要拿着 token 去认证服务端检查 token 的合法性,如果合法,再根据用户所属的角色及具有的权限动态的返回数据
创建并配置认证服务
认证服务的功能,主要是验证帐号密码,存储token,验证token,颁发token,刷新token,这些都是认证服务需要做的
首先引入maven配置
org.springframework.boot
spring-boot-starter-web
org.springframework.cloud
spring-cloud-starter-oauth2
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.boot
spring-boot-starter-actuator
spring-cloud-starter-oauth2
包含了 spring-cloud-starter-security
,所以不用再单独引入了。之所以引入 redis 包,是因为下面会介绍一种用 redis 存储 token 的方式
配置application.yml
spring:
application:
name: auth-server
redis:
database: 2
host: localhost
port: 32768
password: 1qaz@WSX
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
timeout: 100ms
server:
port: 6001
management:
endpoint:
health:
enabled: true
springsecurity基础配置
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/**").permitAll();
}
}
使用@EnableWebSecurity
注解修饰,并继承自WebSecurityConfigurerAdapter
类
这个类的重点就是声明 PasswordEncoder
和 AuthenticationManager
两个 Bean。稍后会用到。其中 BCryptPasswordEncoder
是一个密码加密工具类,它可以实现不可逆的加密,AuthenticationManager
是为了实现 OAuth2 的 password 模式必须要指定的授权管理 Bean
实现UserDetailsService
熟悉security的应该知道,UserDetailsService
是用来对用户身份进行验证的一种方式,主要的方法是loadUserByUsername,它要接收一个字符串参数,也就是传过来的用户名,返回一个 UserDetails
对象
@Slf4j
@Component
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("usernameis:" + username);
// 查询数据库操作
if(!username.equals("admin")){
throw new UsernameNotFoundException("the user is not found");
}else{
// 用户角色也应在数据库中获取
String role = "ROLE_ADMIN";
List authorities = new ArrayList();
authorities.add(new SimpleGrantedAuthority(role));
// 线上环境应该通过用户名查询数据库获取加密后的密码
String password = passwordEncoder.encode("123456");
//最后验证通过后,把用户的账号密码和权限等信息封装到UserDetails中返回给上级
return new org.springframework.security.core.userdetails.User(username,password, authorities);
}
}
}
这里为了做演示,把用户名、密码和所属角色都写在代码里了,正式环境中,这里应该是从数据库或者其他地方根据用户名将加密后的密码及所属角色查出来的。账号 admin ,密码 123456,稍后换取 token 的时候会用到。并且给这个用户设置 "ROLE_ADMIN" 角色。
OAuth配置文件
创建一个配置文件集成自AuthorizationServerConfigurerAdapter
,实现其中的condfigure方法
@Configuration
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Resource
private PasswordEncoder passwordEncoder;
@Resource
private AuthenticationManager authenticationManager;
@Resource
private TokenStore redisTokenStory;
@Resource
private UserDetailsService myUserDetailService;
/**
* 用来配置客户端详情服务(ClientDetailsService),
客户端详情信息在这里进行初始化,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("order-client")
.secret(passwordEncoder.encode("order-secret-8888"))
.authorizedGrantTypes("refresh_token","authorization_code", "password")
.accessTokenValiditySeconds(3600)
.scopes("all")
.and()
.withClient("user-client")
.secret(passwordEncoder.encode("user-secret-8888"))
.authorizedGrantTypes("refresh_token", "authorization_code", "password")
.accessTokenValiditySeconds(3600)
.scopes("all");
}
/**
* 用来配置令牌的访问端点和令牌服务
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(myUserDetailService)
.tokenStore(redisTokenStory);
}
/**
* 用来配置令牌端点的安全约束
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();
security.checkTokenAccess("isAuthenticated()");
security.tokenKeyAccess("isAuthenticated()");
}
}
有三个 configure 方法的重写。
AuthorizationServerEndpointsConfigurer
参数的重写
authenticationManage()
调用此方法才能支持 password 模式。
userDetailsService()
设置用户验证服务。
tokenStore()
指定 token 的存储方式。
redisTokenStore
Bean 的定义如下:
@Configuration
public class RedisTokenStoreConfig {
@Resource
public 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 中
还有一个重写的方法 public void configure(AuthorizationServerSecurityConfigurer security)
,这个方法限制客户端访问认证接口的权限
security.allowFormAuthenticationForClients();
security.checkTokenAccess("isAuthenticated()");
security.tokenKeyAccess("isAuthenticated()");
第一行代码是允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401。
第二行和第三行分别是允许已授权用户访问 checkToken 接口和获取 token 接口。
完成之后,启动项目,如果你用的是 IDEA 会在下方的 Mapping 窗口中看到 oauth2 相关的 RESTful 接口
主要有如下几个:
POST /oauth/authorize 授权码模式认证授权接口
GET/POST /oauth/token 获取 token 的接口
POST /oauth/check_token 检查 token 合法性接口
创建用户客户端项目
application.yml配置文件
spring:
application:
name: client-user
redis:
database: 2
host: localhost
port: 32768
password: 1qaz@WSX
jedis:
pool:
max-active: 8
max-idle: 8
min-idle: 0
timeout: 100ms
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 验证,就是请求的这个接口
资源配置文件
在 OAuth2 的概念里,所有的接口都被称为资源,接口的权限也就是资源的权限,所以 Spring Security OAuth2 中提供了关于资源的注解 @EnableResourceServer
,和 @EnableWebSecurity
的作用类似
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Value("${security.oauth2.client.client-id}")
private String clientId;
@Value("${security.oauth2.client.client-secret}")
private String secret;
@Value("${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 的验证
最后添加一个测试接口
@Slf4j
@RestController
public class UserController {
@GetMapping(value = "get")
//@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()
可以拿到授权信息进行查看
测试认证功能
1、启动认证服务端,启动端口为 6001
2、启动用户服务客户端,启动端口为6101
3、请求认证服务端获取 token
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==,可以通过 www.sojson.com/base64.html 在线编码获取
运行请求后,如果参数都正确的话,获取到的返回内容如下,是一段 json 格式
{
"access_token": "9f958300-5005-46ea-9061-323c9e6c7a4d",
"token_type": "bearer",
"refresh_token": "0f5871f5-98f1-405e-848e-80f641bab72e",
"expires_in": 3599,
"scope": "all"
}
access_token : 就是之后请求需要带上的 token,也是本次请求的主要目的 token_type:为 bearer,这是 access token 最常用的一种形式 refresh_token:之后可以用这个值来换取新的 token,而不用输入账号密码 expires_in:token 的过期时间(秒)
我们在用户客户端中定义了一个接口 http://localhost:6101/client-user/get,现在就拿着上一步获取的 token 来请求这个接口
GET http://localhost:6101/client-user/get
Accept: */*
Cache-Control: no-cache
Authorization: bearer ce334918-e666-455a-8ecd-8bd680415d84
同样需要请求头 Authorization,格式为 bearer + 空格 + token,正常情况下根据接口的逻辑,会把 token 原样返回
token 过期后,用 refresh_token 换取 access_token
一般都会设置 access_token 的过期时间小于 refresh_token 的过期时间,以便在 access_token 过期后,不用用户再次登录的情况下,获取新的 access_token
### 换取 access_token
POST http://localhost:6001/oauth/token?grant_type=refresh_token&refresh_token=706dac10-d48e-4795-8379-efe8307a2282
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 的配置去掉,然后新增一个JwtTokenConfig类
@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 有自身独特的数据格式。如果没有了解过 JWT ,可以搜索一下先了解一下
更改后的OAuth配置类
@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 存储模式
更改用户客户端
修改后的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 ,导致验证不通过
资源服务配置
@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;
}
@Autowired
private TokenStore jwtTokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(jwtTokenStore);
}
}
以上就是 password 模式的完整过程,下一篇更新授权码模式