参考:
官方文档有句话,可以看出官方基本上也是推荐使用OAuth2进行授权管理、JWT作为令牌管理:
基于
Spring Boot
和Spring Security OAuth2
可以很快速创建单点登录、令牌relay和令牌交换。
OAuth 2.0 简单介绍
角色
先区分下OAuth 2.0 中有哪些角色,注意这里根据自己理解来写的,阮一峰博客里写的更精确:
- Client: 客户端,也就是Third-party application - 第三方应用程序
- Service:服务端,也就是服务的提供者
- User: 用户,也就是Resource Owner - 资源所有者
- User Agent:用户代理,如浏览器,下文中将其与Client合并考虑。
- Authorization Server:认证服务器,即服务提供商专门用来处理认证的服务器。
- Resource Server:资源服务器,即服务提供商存放用户生成的资源的服务器。
模式
在不需要第三方认证支持时,我们常用的就是简化模式:
步骤如下:
(A)客户端将用户导向认证服务器。
(B)用户决定是否给于客户端授权。
(C)假设用户给予授权,认证服务器将用户导向客户端指定的"重定向URI",并在URI的Hash部分包含了访问令牌。
(D)浏览器向资源服务器发出请求,其中不包括上一步收到的Hash值。
(E)资源服务器返回一个网页,其中包含的代码可以获取Hash值中的令牌。
(F)浏览器执行上一步获得的脚本,提取出令牌。
(G)浏览器将令牌发给客户端。
上面都是非常严谨的描述,大家可以详细看阮一峰的博客,或者RFC标准, 下面主要分析下微服务下如何
简单对比下简单模式和授权模式的差别:
- 简化模式主要用于用户直接登录,至于是通过web、App还是其它方式是无所谓的,用户将给客户端完全的授权;这里只涉及到用户 -> 客户端 -> 服务提供者
- 授权模式是给第三方部分/全部权限以便于其给用户提供服务,这里涉及到 用户 -> 客户端 -> 第三方服务提供者 -> 服务提供者
重点是授权模式给 第三方部分用户权限,而且这时候客户端大部分也是第三方提供的。
团队学习时,很多人困惑: 为何在授权模式下需要将code给客户端, 然后再让第三方去获取Token?
- 授权模式下,数据是在第三方和后台直接交互,用户只是给了授权,所以Token肯定要给第三方
- 如果直接将Token给客户端,但客户端本身也可能是伪造的,但它拿了code是没有用的,无法访问数据
Spring Cloud 微服务下
Spring Cloud 下我们这里使用简化模式,主要是登录、授权、Token管理,角色大体如下:
- User: 也就是用户,用户一般直接与Client交互,REST API后台一般不需要考虑。
- Gateway + Resource Server :资源服务器对请求进行认证,一般整合在网关中,这样可以很方便的统一处理所有请求。
- Authorization Server: 授权服务器,进行授权和Token管理。
- Client: 调用API的应用,一般是前端、移动App或者第三方应用
- Token Store: 令牌存储,多个服务如果每次请求都通过授权服务器进行Token查询,效率底下,所以需要统一存储、交互令牌信息,常用Redis
- Services: 提供正在业务/功能/API的服务。
大概画个图,这里以Client为前端为例,注意不涉及用户和前端的交互:
- 服务接收请求后,如果需要还会解析Token获取用户信息
- 实际过程中,还会有Token的刷新、删除等操作
- 如果使用JWT,那么可以不保存token,当然也可以保存,Spring中默认JwtTokenStore实际上是没有存储。可以参考 jwt-authentication-how-to-implement-logout
Spring Cloud OAuth
将基本的功能都实现。
JWT 简介
JWT -- Json Web Token, 如其名,使用Json方式保存Web Token的协议。网上有各种解读,个人理解,这就是一个 客户端Session - Session保存在客户端,而不是通常的保存在服务端。
构成
JWT三部分组成:
- Header 头部:JSON方式描述JWT基本信息,如类型和签名算法。使用Base64编码为字符串
- Payload 载荷:JSON方式描述JWT信息,除了标准定义的,还可以添加自定义的信息。同样使用Base64编码为字符串。
- iss: 签发者
- sub: 用户
- aud: 接收方
- exp(expires): unix时间戳描述的过期时间
- iat(issued at): unix时间戳描述的签发时间
- Signature 签名:将前两个字符串用
.
连接后,使用头部定义的加密算法,利用密钥进行签名,并将签名信息附在最后。
注意: Payload 使用 Base64编码,所以就是明文的,不要存放任何机密信息。
优缺点
当然带来一些好处:
- 服务端内存占用少了
- 不需要维护session状态了,真正无状态
- 单点登录 so easy,只要后台服务能解读,Cookie 设置为顶级域名
有好处当然就有不太好的:
- 每个请求就要对JWT进行解密,验证
- Token有效期只有超时,没有退出。当然有一些做法,上面也提到了,jwt-authentication-how-to-implement-logout
- XSS攻击问题,一个讨论: Is it OK to store the JWT in local/session storage
我个人的看法是: 使用JWT,同时在Redis保存信息,在API网关进行详细的验证;各服务则只简单校验Token本身是否篡改。
Spring Cloud OAuth
解读
角色
Spring Cloud OAuth
中将角色为三个,这点从源码中包org.springframework.security.oauth2.config.annotation.web.configurers
中包含三个Enable注解就可以看出来:
- EnableAuthorizationServer -- 使能授权服务器
- EnableResourceServer -- 使能资源服务器
- EnableOAuth2Client -- 使能客户端,如需要第三方授权来调用,应该使用此注解。
AuthorizationServer 授权服务配置
一. 首先当然需要使能,在配置类或 Application 上类添加注解: @EnableAuthorizationServer
,添加该注解后会自动添加OAuth2的多个endpoint, 相关实现代码在包 org.springframework.security.oauth2.provider.endpoint
:
- /oauth/authorize:验证接口, AuthorizationEndpoint
- /oauth/token:获取token
- /oauth/confirm_access:用户授权
- /oauth/error:认证失败
- /oauth/check_token:资源服务器用来校验token
- /oauth/token_key:jwt模式下获取公钥;位于:TokenKeyEndpoint ,通过 JwtAccessTokenConverter 访问key
二. 配置入口为接口:AuthorizationServerConfigurer
, 通过扩展AuthorizationServerConfigurerAdapter
实现来进行配置。
Spring Boot 2中很多 Adapter已经取消,直接利用 Java8 Interface Default特性来实现,不过到我写此文时 security 还没改,当然也许是我没注意到。
三. 简单看一下 AuthorizationServerConfigurer
接口的方法, 一共配置三个属性:
-
AuthorizationServerSecurityConfigurer
:声明安全约束,哪些允许访问,哪些不允许访问。配置AuthorizationServer
的安全属性,也就是endpoint/oauth/token
。/oauth/authorize
则和其它用户 REST 一样保护。可以不配置。 -
ClientDetailsServiceConfigurer
: 配置ClientDetailsService
独立client客户端的信息。包括权限范围、授权方式、客户端权限等配置。授权方式有4种:implicit, client_redentials, password , authorization_code
, 其中密码授权方式必须结合 AuthenticationManager 进行配置。必须至少配置一个客户端。 -
AuthorizationServerEndpointsConfigurer
: 配置AuthorizationServer
端点的非安全属性,也就是 token 存储方式、token 配置、用户授权模式等。默认不需做任何配置,除非使用 密码授权方式, 这时候必须配置 AuthenticationManager。
四. 其中,Token管理:
- Token 生命周期管理接口
AuthorizationServerTokenServices
, 默认使用:DefaultTokenServices
。 - Token存储通过配置
TokenStore
,默认使用内存存储。AuthorizationServerEndpointsConfigurer
或DefaultTokenServices
入口配置。配置方式有- InMemoryTokenStore 默认方式,保存在本地内存
- JdbcTokenStore 存储数据库
- RedisTokenStore 存储Redis,这应该是微服务下比较常用方式
- JwtTokenStore
AccessTokenConverter
五. 加密算法配置
在spring5之后,必须配置加密算法。
- 测试时候可以用无加密算法,参考:no-passwordencoder-mapped-id-null
@SuppressWarnings("deprecation")
@Bean
public NoOpPasswordEncoder passwordEncoder() {
return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
}
- 配置加密算法,当然也可以配置其它算法:
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
六. 实际例子:
代码这里不贴了,可以参考上面的参考材料。
测试:
curl -X POST \
http://127.0.0.1:<端口>/oauth/token \
-H 'Authorization: Basic <xxxxxxx>' \
-H 'Cache-Control: no-cache' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=admin&password=admin&grant_type=password'
其中:Basic <xxxxxxx>
根据client_id和secret计算,我用的postman测试,其中授权方式选择 Basic Auth
, Username就是client_id,secret 就是 password,postman 会自动计算 Authorization 字段。
ResourceServer 资源服务器配置
一. 在配置类或 Application 类上添加注解:@EnableResourceServer
二. 配置接口为: ResourceServerConfigurer
,继承实现ResourceServerConfigurerAdapter
即可。接口有两个配置:
- ResourceServerSecurityConfigurer : 配置资源服务器安全属性,如Token的配置,这些是与 AuthorizationServer 授权服务器的配置是匹配的。
- HttpSecurity : 配置资源的保护
如何整合 ResourceServer 到 zuul,网上有很多教程,这里不多说。但是到目前为止,spring cloud gateway 并没有整合 OAuth2,需要自己实现,且其与 ResourceServer 实现不能整合(也可能我没找到)
https://github.com/spring-cloud/spring-cloud-gateway/issues/179
Spring Cloud Gateway 整合 Security
一些参考
https://github.com/spring-projects/spring-security/issues/4807
https://github.com/spring-cloud/spring-cloud-gateway/issues/144
https://stackoverflow.com/questions/46798705/is-there-working-example-of-oauth2-with-webflux
主要参考:
https://stackoverflow.com/questions/47354171/spring-webflux-custom-authentication-for-api
临时的解决方案,后续Spring应该会将其完全整合。
gradle 添加依赖:
compile 'org.springframework.boot:spring-boot-starter-security'
compile 'io.jsonwebtoken:jjwt:0.9.0'
添加SecurityFilter
@Configuration
@EnableWebFluxSecurity
public class SecurityFilter {
@Autowired
private SecurityContextRepository securityContextRepository;
@Bean
SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception {
return http
// Disable default security.
.csrf().disable()
.httpBasic().disable()
.formLogin().disable()
.logout().disable()
// config auth
.securityContextRepository(securityContextRepository)
// Disable authentication for `/oauth/**` routes.
.authorizeExchange()
.pathMatchers("/oauth/**").permitAll()
.anyExchange().authenticated()
.and()
.build();
}
}
主要的实现在 SecurityContextRepository,下面代码没有完全实现,大体流程:
- 获取Token
- 解析Token,这里用的 jjwt
- 判断Token信息,下面的代码中并没有实现,大家可以自己实现
@Component
public class SecurityContextRepository implements ServerSecurityContextRepository {
private static final Logger logger = LoggerFactory.getLogger(SecurityFilter.class);
@Override
public Mono<Void> save(ServerWebExchange serverWebExchange, SecurityContext securityContext) {
return Mono.empty();
}
@Override
public Mono<SecurityContext> load(ServerWebExchange serverWebExchange) {
// 获取Token
String authHeader = serverWebExchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null) {
logger.warn("not find AUTHORIZATION");
return Mono.empty();
}
String token = authHeader.replace(TOKEN_SCHEME, "").trim();
try {
Claims claims = Jwts.parser().setSigningKey("iotSignKey".getBytes()).parseClaimsJws(token).getBody();
TokenInfo tokenInfo = new TokenInfo(claims);
logger.info("token:{} ", tokenInfo);
// 获取授权信息
List<GrantedAuthority> authorities = tokenInfo.getAuthorities()
.stream()
.map(SimpleGrantedAuthority::new)
.collect(toList());
Authentication authentication = new JwtAuthenticationToken(authorities, tokenInfo.getUserName());
authentication.setAuthenticated(true);
return Mono.justOrEmpty(new SecurityContextImpl(authentication));
} catch (SignatureException e) {
// 验证错误
logger.warn("jwt token parse error: {}", e.getCause());
} catch (ExpiredJwtException e) {
// token 超时
logger.warn("jwt token is expired");
} catch (MalformedJwtException e) {
// token Malformed
logger.warn("jwt token is malformed");
}
return Mono.empty();
}