Spring Cloud 入门 ---- OAuth2 分布式认证授权【随笔】
OAuth2 分布式认证授权
整体流程
架构方案图:
流程:
1、UAA 认证服务负责认证授权。
2、所有请求经过网关到达微服务。
3、网关负责鉴权客户端以及请求转发。
4、网关将 token 解析后传给微服务,微服务进行授权。
整个演示项目包含4个模块:注册中心、认证服务、资源服务、网关服务。
注册中心
我们使用前面创建的
eureka-registry-center
作为注册中心,修改 授权服务 与 资源服务 分别添加注册中心相关配置。具体修改如下:
一、引入 pom 依赖。【授权服务与资源服务都要】
<!-- 引入 eureka client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
二、添加 yml 配置。注意除了服务实例 Id 外其它配置一样,授权服务实例 Id 为
security-oauth2-auth3030
,资源服务实例 Id 为security-oauth2-resource3040
。
eureka-connection:
name: akieay
password: 1qaz2wsx
eureka:
client:
#表示是否将自己注册进 Eureka Server服务 默认为true
register-with-eureka: true
#f是否从Eureka Server抓取已有的注册信息,默认是true。单点无所谓,集群必需设置为true才能配合ribbon使用负载均衡
fetch-registry: true
service-url: # 设置与 Eureka Server 交互的地址 查询服务与注册服务都需要这个地址
# defaultZone: http://localhost:7001/eureka
defaultZone: http://${eureka-connection.name}:${eureka-connection.password}@eureka7001.com:7001/eureka,http://${eureka-connection.name}:${eureka-connection.password}@eureka7002.com:7002/eureka
instance:
instance-id: 服务实例Id
## 当调用getHostname获取实例的hostname时,返回ip而不是host名
prefer-ip-address: true
# Eureka客户端向服务端发送心跳的时间间隔,单位秒(默认30秒)
lease-renewal-interval-in-seconds: 10
# Eureka服务端在收到最后一次心跳后的等待时间上限,单位秒(默认90秒)
lease-expiration-duration-in-seconds: 30
分别启动注册中心集群 、授权服务、资源服务;进入注册中心查看服务注册情况。如下图:
网关
网关整合 OAuth2.0 有两种思路,一种是认证服务器生成 JWT 令牌,所有请求统一在网关层验证,判断权限等操作;另一种是由各资源服务处理,网关只做请求转发。
我们选用第一种。把 API 网关作为 OAuth2.0 的资源服务器角色,实现接入客户端权限拦截、令牌解析并转发当前登录用户信息(jsonToken)给微服务。这样做的好处就是:下游微服务不需要关心令牌格式解析以及 OAuth2.0 相关机制,专注于自身服务。
API 网关在认证授权体系里主要负责两件事:
- 作为 OAuth2.0 的资源服务器角色,实现第三方权限拦截。
- 令牌解析并转发当前登录用户信息(明文 token)给微服务。
微服务拿到明文 token(明文 token 中包含登录用户的身份和权限信息)后也需要做两件事:
- 用户授权拦截(查看当前用户是否有权访问该资源)
- 将用户信息存储进当前线程上下文(有利于后续业务逻辑随时获取当前用户信息)
准备工作
为了方便配置网关服务,我们为 授权服务 与 资源服务 添加
context-path
配置,其中以/auth
为访问路径前缀的是认证服务,以/api
为前缀的是资源服务。具体配置如下:
server:
port: 3030
servlet:
context-path: /auth
server:
port: 3040
servlet:
context-path: /api
使用 Zuul 网关模块
我们使用前面创建的
routing-zuul-service
做为服务网关。
一、修改 yml 配置,具体配置如下:
server:
port: 6070
spring:
main:
allow-bean-definition-overriding: true
application:
name: routing-zuul-servie
zuul:
host:
connect-timeout-millis: 15000 #HTTP连接超时要比Hystrix的大
socket-timeout-millis: 60000 #socket超时
retryable: true
add-proxy-headers: true
#关闭默认路由配置
ignored-services: '*'
#配置过滤敏感的请求头信息,默认不会过滤
sensitive-headers: '*'
add-host-header: true #设置为true重定向是会添加host请求头
routes:
SECURITY-OAUTH2-AUTH-SERVICE:
stripPrefix: false
path: /auth/**
SECURITY-OAUTH2-RESOURCES-SERVICE:
stripPrefix: false
path: /api/**
ribbon:
ReadTimeout: 10000
ConnectTimeout: 10000
feign:
hystrix:
enabled: true
compression:
request:
enabled: true
mime-types:
- text/xml
- application/xml
- application/json
min-request-size: 2048
response:
enabled: true
eureka-connection:
name: akieay
password: 1qaz2wsx
eureka:
client:
#表示是否将自己注册进 Eureka Server服务 默认为true
register-with-eureka: true
#f是否从Eureka Server抓取已有的注册信息,默认是true。单点无所谓,集群必需设置为true才能配合ribbon使用负载均衡
fetch-registry: true
service-url: # 设置与 Eureka Server 交互的地址 查询服务与注册服务都需要这个地址
# defaultZone: http://localhost:7001/eureka
defaultZone: http://${eureka-connection.name}:${eureka-connection.password}@eureka7001.com:7001/eureka,http://${eureka-connection.name}:${eureka-connection.password}@eureka7002.com:7002/eureka
instance:
instance-id: routing-zuul-6070
## 当调用getHostname获取实例的hostname时,返回ip而不是host名
prefer-ip-address: true
# Eureka客户端向服务端发送心跳的时间间隔,单位秒(默认30秒)
lease-renewal-interval-in-seconds: 10
# Eureka服务端在收到最后一次心跳后的等待时间上限,单位秒(默认90秒)
lease-expiration-duration-in-seconds: 30
logging:
level:
root: info
com.akieay.cloud.zuul: debug
二、添加 token 配置。
前面也介绍了,资源服务器由于需要验证并解析令牌,往往可以通过在授权服务器暴露
check_token
的Endpoint
来完成,而我们在授权服务器使用的是对称加密的 jwt ,因此知道**即可,资源服务与授权服务本就是对称设计,那我们把授权服务的TokenConfig
这个类拷贝过来就行。
@Configuration
public class TokenConfig {
/**
* 签名秘钥
*/
private static String SIGNING_KEY = "akieay-security-oauth2-signing-key";
/**
* 令牌存储策略
*
* @return
*/
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
/**
* JWT访问令牌转换器
*
* @return
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
// 对称秘钥,资源服务器使用该**来验证
converter.setSigningKey(SIGNING_KEY);
return converter;
}
}
三、网关资源服务配置。
在
ResouceServerConfig
中定义资源服务配置,主要配置的内容就是定义一些匹配规则,描述某个接入客户端需要什么样的权限才能访问某个微服务,如下:配置了auth
与api
服务的权限校验规则。
@Configuration
public class ResourceServerConfig {
public static final String RESOURCE_ID = "res1";
/**
* auth 授权服务配置
*
*/
@Configuration
@EnableResourceServer
public class AuthServerConfig extends ResourceServerConfigurerAdapter {
@Resource
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
// 资源ID
resources.resourceId(RESOURCE_ID)
// 本地校验令牌
.tokenStore(tokenStore)
//不把token信息记录在session中
.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/auth/**").permitAll();
}
}
/**
* api 资源服务配置
*/
@Configuration
@EnableResourceServer
public class ApiServerConfig extends ResourceServerConfigurerAdapter {
@Resource
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
// 资源ID
resources.resourceId(RESOURCE_ID)
// 本地校验令牌
.tokenStore(tokenStore)
//不把token信息记录在session中
.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/**").access("#oauth2.hasScope('all')");
}
}
}
上面定义了两个微服务的资源,其中:
AuthServerConfig
配置指定:若请求匹配/auth/**
网关不进行拦截。ApiServerConfig
配置指定:若请求匹配/api/**
,则要求接入客户端需要在scope
中包含all
【即要求存在权限范围all
】。
四、安全配置。
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 授权拦截机制
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests()
.antMatchers("/**").permitAll();
}
}
五、转发明文 token 给微服务。
通过 Zuul 过滤器的方式实现,目的是让下游的微服务能够很方便的获取到当前的登录用户信息(明文 token)。
(1)实现 Zuul 前置过滤器,完成当前登录用户信息提取,并放入转发微服务的
request
中。
public class AuthFilter extends ZuulFilter {
@Override
public String filterType() {
return "pre";
}
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
return true;
}
@Override
public Object run() throws ZuulException {
// 1.获取令牌内容
RequestContext ctx = RequestContext.getCurrentContext();
// 从安全上下文中获取 用户身份对象
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (!(authentication instanceof OAuth2Authentication)) {
// 无 token 访问网关内资源的情况,目前仅有 auth 服务直接暴露
return null;
}
OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) authentication;
Authentication userAuthentication = oAuth2Authentication.getUserAuthentication();
OAuth2Request oAuth2Request = oAuth2Authentication.getOAuth2Request();
// 2.组装明文 token,转发给微服务,放入 header,名称为 json-token
Map<String, String> requestParameters = oAuth2Request.getRequestParameters();
Map<String, Object> jsonToken = new HashMap<>(requestParameters);
if (null != userAuthentication) {
// 取出用户身份信息
String name = userAuthentication.getName();
List<String> authorities = new ArrayList<>();
// 获取用户权限信息
userAuthentication.getAuthorities().stream().forEach(c -> authorities.add(c.getAuthority()));
jsonToken.put("principal", name);
jsonToken.put("authorities", authorities);
}
// 把身份信息和权限信息放在 json 中,加入 http 的 header 中
ctx.addZuulRequestHeader("json-token", EncryptUtil.encodeUTF8StringBase64(JSONUtil.toJsonStr(jsonToken)));
return null;
}
}
(2)将 filter 添加到 Spring 容器
@Configuration
public class ZuulConfig {
@Bean
public AuthFilter preFilter() {
return new AuthFilter();
}
@Bean
public FilterRegistrationBean corsFilter() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
final CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setMaxAge(18000L);
source.registerCorsConfiguration("/**", config);
CorsFilter corsFilter = new CorsFilter(source);
FilterRegistrationBean bean = new FilterRegistrationBean(corsFilter);
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
}
微服务解析令牌并鉴权
修改资源服务,当微服务收到明文 token 时,我们可以通过继承
OncePerRequestFilter
实现对微服务的用户鉴权拦截功能。
用户身份信息实体类
@Data
@EqualsAndHashCode(callSuper = false)
public class AccountInfo {
private Integer id;
/**
* 账号
*/
private String accountName;
/**
* 昵称
*/
private String nickName;
/**
* 盐值
*/
private String salt;
/**
* 邮箱
*/
private String email;
/**
* 联系电话
*/
private String phone;
/**
* 状态 ('N': 正常, 'F': 禁用, 'D': 删除)
*/
private String status;
/**
* 创建时间
*/
private Date gmtCreate;
/**
* 创建者
*/
private Integer createAccountId;
/**
* 修改时间
*/
private Date gmtModified;
/**
* 修改者
*/
private Integer modifiedAccountId;
}
令牌解析过滤器
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取 token
String token = request.getHeader("json-token");
if (null != token) {
String json = EncryptUtil.decodeUTF8StringBase64(token);
// 将 token 转成 json 对象
JSONObject jsonObject = JSONUtil.parseObj(json);
// 用户身份信息
String principal = jsonObject.getStr("principal");
AccountInfo accountInfo = new AccountInfo();
accountInfo.setAccountName(principal);
// 用户权限
JSONArray authoritiesArray = jsonObject.getJSONArray("authorities");
String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.toArray().length]);
// 将用户信息和权限填充到用户身份 token 对象中
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(accountInfo,
null, AuthorityUtils.createAuthorityList(authorities));
// 将 authenticationToken 填充到安全上下文
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
}
以上的令牌解析过滤器,解析从 Zuul 网关服务转发过来的 token,并获取其中的用户身份信息 和 用户权限填充到 Security 的安全上下文中,以实现对用户访问的鉴权。
资源服务添加 测试方法
@RestController
@RequestMapping("/v1/test")
public class TestController {
@GetMapping("/getAuthorInfo")
@PreAuthorize("hasAuthority('r:r3')")
public Map<String, Object> getAuthorInfo() {
Map<String, Object> result = new HashMap<>(6);
result.put("author", "akieay");
result.put("date", "2020-11-30");
result.put("method", "getAuthorInfo");
return result;
}
@GetMapping("/getJwtTokenInfo")
@PreAuthorize("hasAuthority('r:r1')")
public JSON getJwtTokenInfo(Authentication authentication, HttpServletRequest request) {
AccountInfo accountInfo = (AccountInfo) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
return JSONUtil.parse(accountInfo);
}
}
集成测试
分别启动
eureka
注册中心集群节点、认证服务auth
、资源服务api
、网关服务zuul
,并打开 eureka 注册中心查看服务注册情况。
使用 密码模式 获取令牌【通过网关】,访问:http://localhost:6070/auth/oauth/token
验证令牌,访问:http://localhost:6070/auth/oauth/check_token
访问资源服务
getJwtTokenInfo
与getAuthorInfo
;由于zhangsan
具有r:r1、r:r2、r:r3
权限,所以这两个接口都能访问;而由于lisi
只具有r:r1、r:r2
没有r:r3
权限,所以只能访问getJwtTokenInfo
没有getAuthorInfo
的访问权限。如下:张三访问:
李四访问:
扩展用户信息
目前 JWT 令牌存储了用户的身份信息、权限信息,网关将 token 明文转发给微服务使用,目前用户身份信息仅包含了用户的账号;在实际开发中可能还需要用户的其它信息,所以我们需要扩展用户信息。
思路:在认证阶段
DaoAuthenticationProvider
会调用UserDetailService
查询用户的信息,这里是可以获取到齐全的用户信息的。由于 JWT 令牌中用户身份信息来源于UserDetails
,UserDetails
中仅定义了username
为用户的身份信息,所以这里提供了两个扩展用户信息的思路:
- 第一是可以扩展
UserDetails
,使之包含更多的自定义属性。- 第二也可以扩展
username
的内容,比如存入用户信息的json
字符串内容作为username
的内容。相比较而言,方案二比较简单还不用破坏
UserDetails
的结构,所以我们采用方案二。具体修改如下:
一、修改 认证服务的
CustomUserDetailsService
,具体修改如下:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysAccountEntity sysAccount = sysAccountMapper.getByAccountName(username);
if (null == sysAccount) {
return null;
}
List<String> permissions = sysAccountMapper.listPermissionByAccountId(sysAccount.getId());
String[] authorities = permissions.toArray(new String[permissions.toArray().length]);
String password = sysAccount.getPassword();
sysAccount.setPassword("");
String accountJson = JSONUtil.toJsonStr(sysAccount);
UserDetails userDetails = User.withUsername(accountJson).password(password)
.authorities(AuthorityUtils.createAuthorityList(authorities)).build();
return userDetails;
}
二、修改 资源服务的
TokenAuthenticationFilter
,具体修改如下:
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 获取 token
String token = request.getHeader("json-token");
if (null != token) {
String json = EncryptUtil.decodeUTF8StringBase64(token);
// 将 token 转成 json 对象
JSONObject jsonObject = JSONUtil.parseObj(json);
// 用户身份信息
String principal = jsonObject.getStr("principal");
AccountInfo accountInfo = JSONUtil.parseObj(principal).toBean(AccountInfo.class);
// 用户权限
JSONArray authoritiesArray = jsonObject.getJSONArray("authorities");
String[] authorities = authoritiesArray.toArray(new String[authoritiesArray.toArray().length]);
// 将用户信息和权限填充到用户身份 token 对象中
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(accountInfo,
null, AuthorityUtils.createAuthorityList(authorities));
// 将 authenticationToken 填充到安全上下文
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
三、重启服务,重新获取令牌,并访问资源服务的
getJwtTokenInfo
接口。这时我们就可以获取到完整的用户信息了。
推荐阅读
-
Spring Cloud下基于OAUTH2认证授权的实现示例
-
Spring Cloud实战 | 最终篇:Spring Cloud Gateway+Spring Security OAuth2集成统一认证授权平台下实现注销使JWT失效方案
-
Spring Cloud下基于OAUTH2认证授权(二)
-
Spring Cloud下基于OAUTH2认证授权(四)
-
Spring Cloud Security OAuth2 实现分布式认证授服务测试
-
Spring Cloud Oauth2实现分布式权限认证(redis版)
-
Spring Cloud:认证 授权 OAuth2、JWT
-
Spring Cloud Oauth2实现分布式权限认证(JWT版)
-
Spring Cloud下基于OAUTH2认证授权的实现示例
-
Spring Cloud 入门 ---- OAuth2 分布式认证授权【随笔】