1. 微服务网关整合 OAuth2.0 设计思路分析
网关整合 OAuth2.0 有两种思路,一种是授权服务器生成令牌, 所有请求统一 在网关层验证,判断权限等操作;另一种是由各资源服务处理,网关只做请求 转发 。 比较常用的是第一种,把 API 网关作为 OAuth2.0 的资源服务器角 色,实现接入客户端权限拦截、令牌解析并转发当前登录用户信息给微服务, 这样下游微服务就不需要关心令牌格式解析以及 OAuth2.0 相关机制了。
网关在认证授权体系里主要负责两件事:
(1)作为 OAuth2.0 的资源服务器 角色,实现接入方访问权限拦截。
(2)令牌解析并转发当前登录用户信息 (明文 token)给微服务
微服务拿到明文 token(明文 token 中包含登录用户的 身份和权限信息)后也需要做两件事:
(1)用户授权拦截(看当前用户是否有 权访问该资源)
(2)将用户信息存储进当前线程上下文(有利于后续业务逻 辑随时获取当前用户信息)
2. 搭建微服务授权中心
授权中心的认证依赖:
- 第三方客户端的信息
- 微服务的信息
- 登录用户的信息
创建微服务 tulingmall-authcenter
2.1 引入依赖
<dependency><groupId>com.alibaba</groupId><artifactId>druid-spring-boot-starter</artifactId>
</dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId>
</dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
</dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId>
</dependency>
2.2 添加 yml 配置
server:port: 9999
spring:application:name: tulingmall-authcenter#配置nacos注册中心地址cloud:nacos:discovery:server-addr: 192.168.65.103:8848 #注册中心地址namespace: 6cd8d896-4d19-4e33-9840-26e4bee9a618 #环境隔离datasource:url: jdbc:mysql://tuling.com:3306/tlmall_oauth?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8username: rootpassword: rootdruid:initial-size: 5 #连接池初始化大小min-idle: 10 #最小空闲连接数max-active: 20 #最大连接数web-stat-filter:exclusions: "*.js,*.gif,*.jpg,*.png,*.css,*.ico,/druid/*" #不统计这些请求数据stat-view-servlet: #访问监控网页的登录用户名和密码login-username: druidlogin-password: druid
2.3 配置授权服务器
基于 DB 模式配置授权服务器存储第三方客户端的信息
@Configuration
@EnableAuthorizationServer
public class TulingAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {@Autowiredprivate DataSource dataSource;@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {// 配置授权服务器存储第三方客户端的信息 基于DB存储 oauth_client_detailsclients.withClientDetails(clientDetails());}@Beanpublic ClientDetailsService clientDetails(){return new JdbcClientDetailsService(dataSource);}}
在 oauth_client_details 中添加第三方客户端信息(client_id client_secret scope 等等)
CREATE TABLE `oauth_client_details` (`client_id` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,`resource_ids` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`client_secret` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`scope` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`authorized_grant_types` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`authorities` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`access_token_validity` int(11) NULL DEFAULT NULL,`refresh_token_validity` int(11) NULL DEFAULT NULL,`additional_information` varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,`autoapprove` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
基于内存模式配置授权服务器存储第三方客户端的信息
//TulingAuthorizationServerConfig.java
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {// 配置授权服务器存储第三方客户端的信息 基于DB存储 oauth_client_details// clients.withClientDetails(clientDetails());/***授权码模式*http://localhost:9999/oauth/authorize?response_type=code&client_id=client&redirect_uri=http://www.baidu.com&scope=all** password模式* http://localhost:8080/oauth/token?username=fox&password=123456&grant_type=password&client_id=client&client_secret=123123&scope=all**/clients.inMemory()//配置client_id.withClient("client")//配置client-secret.secret(passwordEncoder.encode("123123"))//配置访问token的有效期.accessTokenValiditySeconds(3600)//配置刷新token的有效期.refreshTokenValiditySeconds(864000)//配置redirect_uri,用于授权成功后跳转.redirectUris("http://www.baidu.com")//配置申请的权限范围.scopes("all")/*** 配置grant_type,表示授权类型* authorization_code: 授权码* password: 密码* refresh_token: 更新令牌*/.authorizedGrantTypes("authorization_code","password","refresh_token");}
2.4 配置 SpringSecurity
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Autowiredprivate TulingUserDetailsService tulingUserDetailsService;@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// 实现UserDetailsService获取用户信息auth.userDetailsService(tulingUserDetailsService);}@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {// oauth2 密码模式需要拿到这个beanreturn super.authenticationManagerBean();}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.formLogin().permitAll().and().authorizeRequests().antMatchers("/oauth/**").permitAll().anyRequest().authenticated().and().logout().permitAll().and().csrf().disable(); }
}
获取会员信息 ,此处通过 feign 从 tulingmall-member 获取会员信息 ,需要配置 feign ,核心代码:
@Slf4j
@Component
public class TulingUserDetailsService implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 加载用户信息if(StringUtils.isEmpty(username)) {log.warn("用户登陆用户名为空:{}",username);throw new UsernameNotFoundException("用户名不能为空");}UmsMember umsMember = getByUsername(username);if(null == umsMember) {log.warn("根据用户名没有查询到对应的用户信息:{}",username);}log.info("根据用户名:{}获取用户登陆信息:{}",username,umsMember);// 会员信息的封装 implements UserDetailsMemberDetails memberDetails = new MemberDetails(umsMember);return memberDetails;}@Autowiredprivate UmsMemberFeignService umsMemberFeignService;public UmsMember getByUsername(String username) {// fegin获取会员信息CommonResult<UmsMember> umsMemberCommonResult = umsMemberFeignService.loadUserByUsername(username);return umsMemberCommonResult.getData();}
}@FeignClient(value = "tulingmall-member",path="/member/center")
public interface UmsMemberFeignService {@RequestMapping("/loadUmsMember")CommonResult<UmsMember> loadUserByUsername(@RequestParam("username") String username);
}public class MemberDetails implements UserDetails {private UmsMember umsMember;public MemberDetails(UmsMember umsMember) {this.umsMember = umsMember;}@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {//返回当前用户的权限return Arrays.asList(new SimpleGrantedAuthority("TEST"));}@Overridepublic String getPassword() {return umsMember.getPassword();}@Overridepublic String getUsername() {return umsMember.getUsername();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return umsMember.getStatus()==1;}public UmsMember getUmsMember() {return umsMember;}
}
修改授权服务配置,支持密码模式
//TulingAuthorizationServerConfig.java @Autowiredprivate TulingUserDetailsService tulingUserDetailsService;@Autowiredprivate AuthenticationManager authenticationManagerBean;@Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {//使用密码模式需要配置endpoints.authenticationManager(authenticationManagerBean).reuseRefreshTokens(false) //refresh_token是否重复使用.userDetailsService(tulingUserDetailsService) //刷新令牌授权包含对用户信息的检查.allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST); //支持GET,POST请求}/*** 授权服务器安全配置* @param security* @throws Exception*/@Overridepublic void configure(AuthorizationServerSecurityConfigurer security) throws Exception {//第三方客户端校验token需要带入 clientId 和clientSecret来校验security.checkTokenAccess("isAuthenticated()").tokenKeyAccess("isAuthenticated()");//来获取我们的tokenKey需要带入clientId,clientSecret//允许表单认证security.allowFormAuthenticationForClients();}
2.5 测试模拟用户登录
授权码模式
授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然 后再用该码获取令牌。
这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应 用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通 信都在后端完成。这样的前后端分离,可以避免令牌泄漏。
适用场景: 目前市面上主流的第三方验证都是采用这种模式
它的步骤如下:
(A)用户访问客户端,后者将前者导向授权服务器。
(B)用户选择是否给予客户端授权。
(C)假设用户给予授权,授权服务器将用户导向客户端事先指定的"重定向URI"( redirection URI),同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向 URI" ,向授权服务器申请令 牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
(E)授权服务器核对了授权码和重定向 URI ,确认无误后,向客户端发送 访问令牌(access token)和更新令牌( refresh token)。
http://localhost:9999/oauth/authorize?response_type=code&client_id=client &redirect_uri=http://www.baidu.com&scope=all
获取到 code
密码模式
如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该 应 用 。 该 应 用就 使 用你 的 密 码 , 申 请 令 牌 , 这 种 方 式 称 为 " 密 码 式 " (password)。
在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。 这通常用在用户对客户端高度信任的情况下, 比如客户端是操作系统的一部 分,或者由一个著名公司出品。而授权服务器只有在其他授权模式无法执行的 情况下,才能考虑使用这种模式。
适用场景: 自家公司搭建的授权服务器
测试获取 token
http://localhost:9999/oauth/token?username=test&password=test&grant_type=password&client_id=clien t&client_secret= 123123&scope=all
测试校验 token 接口
因为授权服务器的 security 配置需要携带 clientId 和 clientSecret ,可以采用 basic Auth 的方 式发请求
注意: 传参是 token
2.6 配置资源服务器
@Configuration
@EnableResourceServer
public class TulingResourceServerConfig extends ResourceServerConfigurerAdapter {@Overridepublic void configure(HttpSecurity http) throws Exception {http.authorizeRequests().anyRequest().authenticated();}
}@RestController
@RequestMapping("/user")
public class UserController {@RequestMapping("/getCurrentUser")public Object getCurrentUser(Authentication authentication) {return authentication.getPrincipal();}
}
测试携带token 访问资源
或者请求头配置 Authorization
OAuth 2.0 是当前业界标准的授权协议,它的核心是若干个针对不同场景的令牌颁发和管 理流程;而 JWT 是一种轻量级、 自包含的令牌,可用于在微服务间安全地传递用户信息。
2.7 Spring Security Oauth2 整合 JWT
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、 自包含的协议格式,用于在通信双方传递 json 对象,传递的信息经过数 字签名可以被验证和信任。JWT 可以使用 HMAC 算法或使用 RSA 的公钥/私钥对 来签名,防止被篡改。 官网:JSON Web Tokens - jwt.io
JWT 令牌的优点:
- jwt 基于 json ,非常方便解析。
- 可以在令牌中自定义丰富的内容,易扩展。
- 通过非对称加密算法及数字签名技术,JWT 防止篡改,安全性高。
- 资源服务使用JWT 可不依赖认证服务即可完成授权。
- 通过非对称加密算法及数字签名技术,JWT 防止篡改,安全性高。
- 可以在令牌中自定义丰富的内容,易扩展。
缺点:
JWT 令牌较长, 占存储空间比较大。
JWT:指的是 JSON Web Token , 由 header.payload.signture 组成。不存在签名的 JWT 是 不安全的,存在签名的 JWT 是不可窜改的。
JWS:指的是签过名的 JWT ,即拥有签名的 JWT。
JWK:既然涉及到签名,就涉及到签名算法,对称加密还是非对称加密,那么就需要加密 的 密钥或者公私钥对。此处我们将 JWT 的密钥或者公私钥对统一称为 JSON WEB KEY ,即 JWK。
JWT 组成
一个 JWT 实际上就是一个字符串,它由三部分组成,头部(header) 、载荷 (payload)与签名(signature)。
头部(header)
头部用于描述关于该 JWT 的最基本的信息:类型(即 JWT)以及签名所用的 算法(如 HMACSHA256 或 RSA)等。
这也可以被表示成一个 JSON 对象:
{"alg": "HS256","typ": "JWT"
}
然后将头部进行 base64 加密(该加密是可以对称解密的),构成了第一部分:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
载荷(payload)
第二部分是载荷,就是存放有效信息的地方。这个名字像是特指飞机上承载的 货品,这些有效信息包含三个部分:
- 标准中注册的声明(建议但不强制使用)
iss: jwt 签发者
sub: jwt 所面向的用户
aud: 接收 jwt 的一方
exp: jwt 的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该 jwt 都是不可用的.
iat: jwt 的签发时间
jti: jwt 的唯一身份标识,主要用来作为一次性 token,从而回避重放攻击。
- 公共的声明
公共的声明可以添加任何的信息,一般添加用户的相关信息或 其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
- 私有的声明
私有声明是提供者和消费者所共同定义的声明,一般不建议存 放敏感信息,因为 base64 是对称解密的,意味着该部分信息可以归类为明文 信息。
定义一个 payload:
{"sub": "1234567890","name": "John Doe","iat": 1516239022
}
然后将其进行 base64 加密,得到 Jwt 的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
签名(signature)
jwt 的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64 后的)
- payload (base64 后的)
- secret(盐,一定要保密)
这个部分需要 base64 加密后的 header 和 base64 加密后的 payload 使用.连接 组成的字符串,然后通过 header 中声明的加密方式进行加盐 secret 组合加 密,然后就构成了 jwt 的第三部分:
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'fox'); // khA7TNYc7_0iELcDyTc7gHBZ_xfIcgbfpzUNWwQtzME
将这三部分用.连接成一个完整的字符串,构成了最终的 jwt:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.khA7TNYc7_0iELcDyTc7gHBZ_xfIcgbfpzUNWwQtzME
注意:secret 是保存在服务器端的,jwt 的签发生成也是在服务器端的,secret 就是用来进行 jwt 的签发和 jwt 的验证,所以,它就是你服务端的私钥,在任何 场景都不应该流露出去。一旦客户端得知这个 secret, 那就意味着客户端是可以 自我签发 jwt 了。
JWT 应用场景
- 一次性验证
比如用户注册后需要发一封邮件让其激活账户,通常邮件中需要有一个链接,这个链接需 要具备以下的特性:能够标识用户,该链接具有时效性〈(通常只允许几小时之内激活) ,不 能被篡改以激活其他可能的账户…这种场景就和 jwt 的特性非常贴近,jwt 的 payload 中固定 的参数: iss 签发者和 exp 过期时间正是为其做准备的。
- restful api 的无状态认证
使用 jwt 来做 restful api 的身份认证也是值得推崇的一种使用方案。客户端和服务端共享 secret;过期时间由服务端校验,客户端定时刷新;签名信息不可被修改。
- 使用 jwt 做单点登录+会话管理(不推荐) token+redis
jwt 是无状态的,在处理注销,续约问题上会变得非常复杂
引入依赖
<!--spring secuity对jwt的支持 spring cloud oauth2已经依赖,可以不配置-->
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-jwt</artifactId><version>1.0.9.RELEASE</version>
</dependency>
添加 JWT 配置
@Configuration
public class JwtTokenStoreConfig {@Beanpublic TokenStore jwtTokenStore(){return new JwtTokenStore(jwtAccessTokenConverter());}@Beanpublic JwtAccessTokenConverter jwtAccessTokenConverter(){JwtAccessTokenConverter accessTokenConverter = newJwtAccessTokenConverter();//配置JWT使用的秘钥accessTokenConverter.setSigningKey("123123");return accessTokenConverter;}
}
在授权服务器配置中指定令牌的存储策略为 JWT
//TulingAuthorizationServerConfig.java@Autowired
@Qualifier("jwtTokenStore")
private TokenStore tokenStore;@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;@Autowired
private TulingUserDetailsService tulingUserDetailsService;@Autowired
private AuthenticationManager authenticationManagerBean;@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {//使用密码模式需要配置endpoints.authenticationManager(authenticationManagerBean).tokenStore(tokenStore) //指定token存储策略是jwt.accessTokenConverter(jwtAccessTokenConverter).reuseRefreshTokens(false) //refresh_token是否重复使用.userDetailsService(tulingUserDetailsService) //刷新令牌授权包含对用户信息的检查.allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST); //支持GET,POST请求
}
密码模式测试:
http://localhost:9999/oauth/token?username=test&password=test&grant_type=password&client_id=clien t&client_secret=123123&scope=all
将 access_token 复制到 JSON Web Tokens - jwt.io的 Encoded 中打开,可以看到会员认证信息
测试校验 token
测试获取 token_key
测试刷新 token