Oauth2+Gateway+JWT+RSA 解决微服务单点登录问题
注意
2021-01-06日更新:
本文中同时使用了jwt+redis来解决分布式会话问题,实际应用中,这属于多此一举,jwt的特点就是不依赖服务端做会话管理,如果出于安全考虑需要服务端管理,直接使用redis+session方案即可,不需要使用jwt。
解决微服务单点登录问题,涉及使用JWT存储令牌,RSA加密,Redis存储短令牌,网关权限控制。
模型:
# 准备工作
redis:已安装到192.168.200.128:6379
mysql:安装到192.168.200.128:3306
建表:oauth_client_details
这个表是固定格式的,oauth规定的。
/*
SQLyog Ultimate v12.08 (32 bit)
MySQL - 5.7.26-log : Database - test
*********************************************************************
*/
/*!40101 SET NAMES utf8 */;
/*!40101 SET SQL_MODE=''*/;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
CREATE DATABASE /*!32312 IF NOT EXISTS*/`test` /*!40100 DEFAULT CHARACTER SET utf8 */;
USE `test`;
/*Table structure for table `oauth_client_details` */
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(48) NOT NULL COMMENT '客户端ID,主要用于标识对应的应用',
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL COMMENT '客户端秘钥,BCryptPasswordEncoder加密',
`scope` varchar(256) DEFAULT NULL COMMENT '对应的范围',
`authorized_grant_types` varchar(256) DEFAULT NULL COMMENT '认证模式',
`web_server_redirect_uri` varchar(256) DEFAULT NULL COMMENT '认证后重定向地址',
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL COMMENT '令牌有效期',
`refresh_token_validity` int(11) DEFAULT NULL COMMENT '令牌刷新周期',
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;
/*Data for the table `oauth_client_details` */
insert into `oauth_client_details`(`client_id`,`resource_ids`,`client_secret`,`scope`,`authorized_grant_types`,`web_server_redirect_uri`,`authorities`,`access_token_validity`,`refresh_token_validity`,`additional_information`,`autoapprove`) values ('dram',NULL,'$2a$10$T5Q3szoTbBw77LgyzdFVou9AocVaNgAlJQwiW7o7JlngwouB3KuaS','app','authorization_code,password,refresh_token,client_credentials','http://localhost',NULL,43200,43200,NULL,NULL);
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
字段详解:
字段名 | 字段约束 | 详细描述 | 范例 |
---|---|---|---|
client_id | 主键,必须唯一,不能为空 | 用于唯一标识每一个客户端(client);注册时必须填写(也可以服务端自动生成),这个字段是必须的,实际应用也有叫app_key | dram |
resource_ids | 不能为空,用逗号分隔 | 客户端能访问的资源id集合,注册客户端时,根据实际需要可选择资源id,也可以根据不同的额注册流程,赋予对应的额资源id | order-resource,pay-resource |
client_secret | 必须填写 | 注册填写或者服务端自动生成,实际应用也有叫app_secret | $2a$10$T5Q3szoTbBw77LgyzdFVou9AocVaNgAlJQwiW7o7JlngwouB3KuaS |
scope | 不能为空,用逗号分隔 | 指定client的权限范围,比如读写权限,比如移动端还是web端权限 | read,write / web,mobile |
authorized_grant_types | 不能为空 | 可选值 授权码模式:authorization_code,密码模式:password,刷新token: refresh_token, 隐式模式: implicit: 客户端模式: client_credentials。支持多个用逗号分隔 | password,refresh_token |
web_server_redirect_uri | 可为空 | 客户端重定向uri,authorization_code和implicit需要该值进行校验,注册时填写 | http:/localhost |
authorities | 可为空 | 指定用户的权限范围,如果授权的过程需要用户登陆,该字段不生效,implicit和client_credentials需要 | ROLE_ADMIN,ROLE_USER |
access_token_validity | 可空 | 设置access_token的有效时间(秒),默认(606012,12小时) | 3600 |
refresh_token_validity | 可空 | 设置refresh_token有效期(秒),默认(606024*30, 30填) | 7200 |
additional_information | 可空 | 值必须是json格式 | {"key", "value"} |
autoapprove | false/true/read/write | 默认false,适用于authorization_code模式,设置用户是否自动approval操作,设置true跳过用户确认授权操作页面,直接跳到redirect_uri | false |
生成AES秘钥对
keytool -genkeypair -alias dram -keyalg RSA -keypass dram.com -keystore dram.jks -storepass dram.com
-alias:密钥的别名 -keyalg:使用的hash算法 -keypass:密钥的访问密码 -keystore:密钥库文件名,changgou.jks保存了生成的证书 -storepass:密钥库的访问密码
用openssl导出公钥
keytool -list -rfc --keystore dram.jks | openssl x509 -inform pem -pubkey
# 搭建springboot项目
# Ureka 服务中心
配置文件:
server:
port: 6868
eureka:
client:
register-with-eureka: false #是否将自己注册到eureka中
fetch-registry: false #是否从eureka中获取信息
service-url:
defaultZone: http://127.0.0.1:${server.port}/eureka/
2
3
4
5
6
7
8
pom:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
2
3
4
# 搭建 Oauth 认证中心服务
pom依赖:
<!--MySQL数据库驱动-->
<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.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>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
配置文件:
*encrypt下的信息和上面生成秘钥时保持一致
spring:
application:
name: auth-service
datasource:
url: jdbc:mysql://192.168.200.128:3306/test?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&autoReconnect=true&allowMultiQueries=true&useServerPrepStmts=true&cachePrepStmts=true
username: root
password: root
redis:
host: 192.168.200.128
server:
port: 6001
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
encrypt:
key-store:
location: classpath:/dram.jks
secret: dram.com
alias: dram
password: dram.com
auth:
ttl: 3600
clientId: dram
clientSecret: 1234
cookieDomain: localhost
cookieMaxAge: -1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 自定义身份验证转换器,用来在jwt载荷添加自定义信息
默认的转换器不能携带id,这里自定义了身份转换器,就可以携带自定义的载荷了。
/**
* @author by Dragon
* @Classname aa
* @Description 自定义身份验证转换器,用来在jwt载荷添加自定义信息
* @Date 2020/4/3 19:58
* @Version 1.0
*/
@Component
public class MyUserAuthenticationConverter extends DefaultUserAuthenticationConverter {
@Autowired
UserDetailsService userDetailsServiceImpl;
/**
* 转换身份验证
*
* @param authentication
* @return
*/
@Override
public Map<String, ?> convertUserAuthentication(Authentication authentication) {
Map<String, Object> response = new LinkedHashMap();
//添加自定义信息
response.put("info", "自定义信息");
String name = authentication.getName();
//添加用户名
response.put("username", name);
//如果要获取用户ID可以在UserDetailsService中封装自定义的UserDetail对象,存放ID值
//可以在这里通过当前身份的主体拿出对象,取出ID设置到结果中
Object principal = authentication.getPrincipal();
if (principal instanceof MyUser) {
String id = ((MyUser) principal).getId();
response.put("id", id);
}
//如果权限集合不为空 重新拼装权限集合 如果没有这一步出来的JWT不携带权限信息
if (authentication.getAuthorities() != null && !authentication.getAuthorities().isEmpty()) {
response.put("authorities", AuthorityUtils.authorityListToSet(authentication.getAuthorities()));
}
return response;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# JwtTokenConfig 配置jwt令牌
主要是设置了自定义身份转换器,和读取了私钥做jwt加密。
*注意要在resources中添加dram.jks文件 这是准备工作中生成的
@Configuration
public class JwtTokenConfig {
/**
* 会读取配置文件封装:
* encrypt:
* key-store:
* location: classpath:/dram.jks
* secret: dram.com
* alias: dram
* password: dram.com
* @return 秘钥对象
*/
@Bean(name = "keyProp")
KeyProperties keyProperties() {
return new KeyProperties();
}
@Resource(name = "keyProp")
private KeyProperties keyProperties;
/**
* @param jwtAccessTokenConverter 自定义令牌转换器
* @return 令牌仓库
*/
@Bean
public TokenStore jwtTokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
/**
* @param myUserAuthenticationConverter 自定义身份验证转换器
* @return JWT令牌转换器
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(MyUserAuthenticationConverter myUserAuthenticationConverter) {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
//读取私钥
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(
keyProperties.getKeyStore().getLocation(),
keyProperties.getKeyStore().getSecret().toCharArray()
);
KeyPair keyPair = keyStoreKeyFactory.getKeyPair(
keyProperties.getKeyStore().getAlias(),
keyProperties.getKeyStore().getPassword().toCharArray()
);
//设置用RSA私钥加密
converter.setKeyPair(keyPair);
//设置自定义转换器 用来在jwt载荷添加自定义信息
DefaultAccessTokenConverter accessTokenConverter = (DefaultAccessTokenConverter) converter.getAccessTokenConverter();
accessTokenConverter.setUserTokenConverter(myUserAuthenticationConverter);
return converter;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# WebSecurityConfig 继承 WebSecurityConfigurerAdapter
配置访问规则
- WebSecurity 全局请求忽略规则配置(比如说静态文件,注册页面)
- HttpSecurity 具体的权限控制规则配置。一个这个配置相当于xml配置中的一个标签。
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
public void configure(WebSecurity web) throws Exception {
//忽略权限认证,否则操作将需要验证basic auth的客户端信息
web.ignoring().antMatchers(
"/oauth/login",
"/oauth/logout");
}
/**
* 允许匿名访问所有接口 主要是 oauth 接口
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.requestMatchers().anyRequest()
.and()
.authorizeRequests()
//验证所有/oauth/下的访问
.antMatchers("/oauth/*").permitAll();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- HttpSecurity 各种具体的认证机制的相关配置:
方法 | 说明 |
---|---|
openidLogin() | 用于基于 OpenId 的验证 |
headers() | 将安全标头添加到响应 |
cors() | 配置跨域资源共享( CORS ) |
sessionManagement() | 允许配置会话管理 |
portMapper() | 允许配置一个PortMapper(HttpSecurity#(getSharedObject(class))),其他提供SecurityConfigurer的对象使用 PortMapper 从 HTTP 重定向到 HTTPS 或者从 HTTPS 重定向到 HTTP。默认情况下,Spring Security使用一个PortMapperImpl映射 HTTP 端口8080到 HTTPS 端口8443,HTTP 端口80到 HTTPS 端口443 |
jee() | 配置基于容器的预认证。 在这种情况下,认证由Servlet容器管理 |
x509() | 配置基于x509的认证 |
rememberMe | 允许配置“记住我”的验证 |
authorizeRequests() | 允许基于使用HttpServletRequest限制访问 |
requestCache() | 允许配置请求缓存 |
exceptionHandling() | 允许配置错误处理 |
securityContext() | 在HttpServletRequests之间的SecurityContextHolder上设置SecurityContext的管理。 当使用WebSecurityConfigurerAdapter时,这将自动应用 |
servletApi() | 将HttpServletRequest方法与在其上找到的值集成到SecurityContext中。 当使用WebSecurityConfigurerAdapter时,这将自动应用 |
csrf() | 添加 CSRF 支持,使用WebSecurityConfigurerAdapter时,默认启用 |
logout() | 添加退出登录支持。当使用WebSecurityConfigurerAdapter时,这将自动应用。默认情况是,访问URL”/ logout”,使HTTP Session无效来清除用户,清除已配置的任何#rememberMe()身份验证,清除SecurityContextHolder,然后重定向到”/login?success” |
anonymous() | 允许配置匿名用户的表示方法。 当与WebSecurityConfigurerAdapter结合使用时,这将自动应用。 默认情况下,匿名用户将使用org.springframework.security.authentication.AnonymousAuthenticationToken表示,并包含角色 “ROLE_ANONYMOUS” |
formLogin() | 指定支持基于表单的身份验证。如果未指定FormLoginConfigurer#loginPage(String),则将生成默认登录页面 |
oauth2Login() | 根据外部OAuth 2.0或OpenID Connect 1.0提供程序配置身份验证 |
requiresChannel() | 配置通道安全。为了使该配置有用,必须提供至少一个到所需信道的映射 |
httpBasic() | 配置 Http Basic 验证 |
addFilterAt() | 在指定的Filter类的位置添加过滤器 |
# OAuth2Config 继承 AuthorizationServerConfigurerAdapter
重写三个configure
- AuthorizationServerEndpointsConfigurer 配置授权服务器的Token存储方式、Token配置、授权模式
- ClientDetailsServiceConfigurer clients 配置客户端认证方式。
- AuthorizationServerSecurityConfigurer 用来配置令牌端点(Token Endpoint)的安全约束.
@Configuration
@EnableAuthorizationServer
public class OAuth2Config extends AuthorizationServerConfigurerAdapter {
@Autowired
public PasswordEncoder passwordEncoder;
@Autowired
public UserDetailsService userDetailsServiceImpl;
/**
* 从WebSecurityConfig中注入
*/
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private DataSource dataSource;
@Autowired
private TokenStore jwtTokenStore;
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
MyUserAuthenticationConverter myUserAuthenticationConverter;
@Override
public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.tokenStore(jwtTokenStore)//配置令牌仓库
.userDetailsService(userDetailsServiceImpl)//配置用户加载类
.authenticationManager(authenticationManager)//配置身份验证管理器
.accessTokenConverter(jwtAccessTokenConverter);//配置令牌转换器
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(dataSource);//配置客户端验证为数据源模式
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients();//允许客户端用表单校验
security.checkTokenAccess("isAuthenticated()");
security.tokenKeyAccess("isAuthenticated()");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 自定义的User类 继承spring提供的User 用于封装自定义字段
public class MyUser extends User {
private String id;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public MyUser(String id, String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.id = id;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# UserDetailsService
实现loadUserByUsername 接收一个字符串用户名,返回一个 UserDetails对象
@Component(value = "userDetailsServiceImpl")
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//模拟查询数据库
String usernameFromDB = "admin";
String userRoleFromDB = "ROLE_ADMIN";
//需要一个BCrypt格式的密码
String userPasswordFromDB = "$2a$10$QJ05fTPm64lUdvF6MKEs3.fI5iBc1Wy459nNK3pUV5NIIA/q9J4A2";
String userIdFromDB="dddddd";
if (!username.equals(usernameFromDB)) {
throw new UsernameNotFoundException("the user is not found");
} else {
return new MyUser(userIdFromDB,username, userPasswordFromDB,
//逗号分隔的权限列表 如:"ADD,UPDATE,DELETE"
AuthorityUtils.commaSeparatedStringToAuthorityList(userRoleFromDB));
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 测试1:到这里可以直接对OAuth接口测试:
oauth2密码模式申请令牌的接口:get http://localhost:6001/oauth/token
需要参数 grant_type=password&username=admin&password=1234
需要在请求头中携带 BasicAuth 格式是 [Basic 客户端密码:客户端凭证的base64]
Authorization:Basic ZHJhbToxMjM0 。可以直接在postman中设置:
响应结果如下:
# 搭建用户登录的入口
service
public interface AuthService {
/**
* 申请令牌
* @param clientId 客户端id
* @param clientSecret 客户端凭证
* @param username 用户名
* @param password 用户密码
* @return
*/
String applyToken(String clientId, String clientSecret, String username, String password);
}
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
LoadBalancerClient loadBalancerClient;
@Autowired
RestTemplate restTemplate;
@Autowired
StringRedisTemplate stringRedisTemplate;
@Value("${auth.ttl}")
private Long ttl;
/**
* 申请令牌
* @param clientId 客户端id
* @param clientSecret 客户端凭证
* @param username 用户名
* @param password 用户密码
* @return
*/
@Override
public String applyToken(String clientId, String clientSecret, String username, String password) {
//从负载均衡中获取服务,基于熔断器
ServiceInstance authService = loadBalancerClient.choose("auth-service");
if (authService == null) {
throw new RuntimeException("授权服务不在线");
}
//拼接OAuth2申请令牌的地址
String url = authService.getUri() + "/oauth/token";
//封装请求头 客户id和客户凭证 封装成basicAuth格式
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
headers.add("Authorization", getBasic(clientId, clientSecret));
//封装请求体 设置授权模式,用户名密码
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "password"); //授权模式:密码模式
body.add("username", username); //用户名
body.add("password", password); //用户密码
//构建请求对象
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(body, headers);
//发送请求
restTemplate.setErrorHandler(new DefaultResponseErrorHandler() {
//400和401响应码不抛出异常
@Override
public void handleError(ClientHttpResponse response) throws IOException {
if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
super.handleError(response);
}
}
});
//拿到响应信息
ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, httpEntity, Map.class);
//获取响应体
Map respMap = response.getBody();
if (respMap == null || respMap.get("access_token") == null || respMap.get("refresh_token") == null || respMap.get("jti") == null) {
throw new RuntimeException("申请令牌失败");
}
//拿到令牌和短令牌
String access_token = String.valueOf(respMap.get("access_token"));
String jti = String.valueOf(respMap.get("jti"));
//保存短令牌和全令牌到redis 方便通过短令牌查找长令牌
stringRedisTemplate.opsForValue().set(jti, access_token, ttl, TimeUnit.SECONDS);
return jti;
}
/**
* 获取basic Auth头
* @param clientId 客户端id
* @param clientSecret 客户端凭证
* @return basic Auth
*/
private String getBasic(String clientId, String clientSecret) {
String basic = clientId + ":" + clientSecret;
return "Basic " + Base64Utils.encodeToString(basic.getBytes(Charset.defaultCharset()));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
这里主要做了两件事,一个是封装了标准的申请令牌的请求,把配置文件中的客户端凭证封装到basicauth中,
一个是在申请令牌成功后,把短令牌和全令牌对应关系存到了redis中,只把短令牌返回。
这样就实现了不把客户端凭证暴露给用户,也不把完整jwt令牌暴露给用户的令牌申请。
controller
这个入口我们已经在上面的配置中忽略了权限验证,如果不忽略的话需要携带basicauth头才能访问。
主要是把用户的短令牌存到了cookies中,后面我们可以拿到短令牌去redis中查对应的全令牌,并在服务转发前拼接到请求头,实现权限信息的传递。
@Controller
@RequestMapping("/oauth")
public class OAuthLoginController {
@Autowired
private AuthService authService;
@Value("${auth.clientId}")
private String clientId;
@Value("${auth.clientSecret}")
private String clientSecret;
@Value("${auth.cookieDomain}")
private String cookieDomain;
@Value("${auth.cookieMaxAge}")
private Integer cookieMaxAge;
/**
* 用户登录接口
*
* @param username 用户名
* @param password 密码
* @param ReturnUrl 登陆成功后跳转的地址
* @return 重定向到ReturnUrl
*/
@PostMapping("/login")
public String login(
@RequestParam("username") String username,
@RequestParam("password") String password,
@RequestParam(name = "ReturnUrl", required = false, defaultValue = "https://dra-m.com") String ReturnUrl) {
if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password)) {
return "用户名及密码必须填写";
}
//调用登录服务 把短令牌和全令牌存到了redis里 返回短令牌
String jti = authService.applyToken(clientId, clientSecret, username, password);
//把短令牌存储到用户端cookie,后面会在网关通过cookie中取短令牌,
//再到redis中取对应的全令牌,在网关转发服务的时候为请求头拼接全令牌
//这样做是为了在jwt过期前能够强制剔除用户授权信息
saveJtiToCookie(jti);
//登录成功之后,重定向到ReturnUrl
return "redirect:" + ReturnUrl;
}
@GetMapping("/login")
@ResponseBody
public String login() {
return "还没有登录页面";
}
/**
* 保存jti到cookie
* @param jti jti
*/
void saveJtiToCookie(String jti) {
//拿到当前的响应对象
ServletRequestAttributes requestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletResponse response = requestAttributes.getResponse();
Cookie cookie = new Cookie("jti", jti);
cookie.setDomain(cookieDomain);
cookie.setPath("/");
cookie.setMaxAge(cookieMaxAge);
//设置只读
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 测试2:到这里我们可以访问该接口,看看在cookies中是不是存了一个jti
post http://localhost:6001/oauth/login?username=admin&password=1234
其实这个接口就是把只有username和password的请求,封装成了测试1的样子发送给了oauth接口。
响应结果:这里请求成功后会跳转到ReturnUrl,如果不填的话会跳转到dra-m.com
# 搭建资源服务器
建一个user-service作为资源服务器
pom:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
<version>2.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.67</version>
</dependency>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
yml:
spring:
application:
name: user-service
server:
port: 6101
auth:
ttl: 3600
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
2
3
4
5
6
7
8
9
10
11
# 资源服务器配置类
这里主要配置了用公钥解密
*注意,要把准备中导出的公钥文件放在resources下
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)//开启全局权限验证
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "pub.key";
@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* 配置用公钥解密
* @return
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
accessTokenConverter.setVerifierKey(getPubKey());
return accessTokenConverter;
}
@Autowired
private TokenStore jwtTokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(jwtTokenStore);//设置令牌仓库
}
/**
* 获取公钥
* @return 公钥
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
自定义jwt解析器
因为spring提供的authentication不能解析载荷信息,所以我们要自己解析jwt才能拿到载荷。
@Component
public class TokenDecode {
//公钥
private static final String PUBLIC_KEY = "pub.key";
private static String publickey = "";
/***
* 获取用户信息
* @return
*/
public Map<String, String> getUserInfo() {
//获取授权信息
OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) SecurityContextHolder.getContext().getAuthentication().getDetails();
//令牌解码
return dcodeToken(details.getTokenValue());
}
/***
* 读取令牌数据
*/
public Map<String, String> dcodeToken(String token) {
//校验Jwt
Jwt jwt = JwtHelper.decodeAndVerify(token,new RsaVerifier(getPubKey()));
//获取Jwt原始内容
String claims = jwt.getClaims();
return JSON.parseObject(claims, Map.class);
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
public String getPubKey() {
if (!StringUtils.isEmpty(publickey)) {
return publickey;
}
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
publickey = br.lines().collect(Collectors.joining("\n"));
return publickey;
} catch (IOException ioe) {
return null;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
controller
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
TokenDecode tokenDecode;
@GetMapping("/get")
@PreAuthorize("hasAnyRole('ROLE_ADMIN')")
public String get(Authentication authentication) {
//用Spring提供的权限对象获取当前权限信息中的用户名
String name = authentication.getName();
//用自定义的jwt解析 解析当前用户的令牌 拿到自定义信息
Map<String, String> userInfo = tokenDecode.getUserInfo();
String info = userInfo.get("info");
String id = userInfo.get("id");
return String.format("你好:%s,你留下的信息是:%s,你的id是:%s", name, info, id);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 测试3:到这里,我们可以通过自己携带oauth2生成的token来访问接口
用测试1中返回的token,携带到请求头,
Authorization: Bearer 全令牌(注意空格)。
也可以让postman生成。
响应结果:
# 搭建 Gateway 网关服务
Oauth2权限控制需要在请求服务的请求头中携带tocken证明自己的身份,可以写一个过滤器封装一个请求头。
而且我们之前在用户cookies中存入了短令牌,我们去redis把全令牌查询出来,封装到请求头的Authorization: Bearer格式中。
pom:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
yml:
spring:
application:
name: gateway-service
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]': # 匹配所有请求
allowedOrigins: "*" #跨域处理 允许所有的域
allowedMethods: # 支持的方法
- GET
- POST
- PUT
- DELETE
routes:
- id: user_service
uri: lb://user-service
predicates:
- Path=/user/**
- id: auth-service
uri: lb://auth-service
predicates:
- Path=/oauth/**
redis:
host: 192.168.200.128
server:
port: 8001
eureka:
client:
service-url:
defaultZone: http://127.0.0.1:6868/eureka
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
权限过滤器:
这里就是从用户的cookies中取出我们存入的短令牌,再从redis中取出对应的全令牌,
拼装成测试2的样子发给资源服务器(user-service)
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Autowired
StringRedisTemplate stringRedisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpResponse response = exchange.getResponse();
ServerHttpRequest request = exchange.getRequest();
//获取请求路径,如果是登录请求就直接放行
String path = request.getURI().getPath();
if (path.equals("oauth/login")) {
return chain.filter(exchange);
}
//获取cookie 判断是否有jti
String jti = request.getCookies().getFirst("jti").getValue();
if (StringUtils.isEmpty(jti)) {
//重定向到登录页面,并拼接当前访问地址为
response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().set("Location", "oauth/login?ReturnUrl" + request.getURI());
return response.setComplete();
}
//根据jti查询完整令牌
String token = getTokenFromRedis(jti);
if (StringUtils.isEmpty(token)) {
//如果获取不到 也跳转到登录页面
response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().set("Location", "oauth/login?ReturnUrl" + request.getURI());
return response.setComplete();
}
//拼接令牌到请求头 放行到服务
request.mutate().header("Authorization","bearer "+ token);
return chain.filter(exchange);
}
/**
* 从Redis中拿到完整令牌
* @param jti 短令牌
* @return 令牌
*/
public String getTokenFromRedis(String jti) {
String token = stringRedisTemplate.opsForValue().get(jti);
return token;
}
@Override
public int getOrder() {
return 0;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
因为我们通过网关直接放行了登录接口,所以现在也可以直接对网关请求测试2中的接口。
http://localhost:8001/oauth/login?username=admin&password=1234
# 测试4
在完成测试2后,已存在cookies的情况下,通过网关访问资源服务器:
http://localhost:8001/user/get
响应结果:
# 扩展:服务之间用feign调用
用feign调用其他资源服务时不带请求头,需要写一个feign拦截器,拼接当前的权限请求头后请求。
/**
* 自定义拦截器, 拦截所有请求
* 每次微服务调用之前都先检查下头文件,将请求的头文件中的令牌数据再放入到header中
*/
@Component
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes!=null){
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
if (request!=null){
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames!=null){
while (headerNames.hasMoreElements()){
String headerName = headerNames.nextElement();
if (headerName.equals("authorization")){
String headerValue = request.getHeader(headerName);
requestTemplate.header(headerName,headerValue);
}
}
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# git
https://github.com/moxiaolong/oauth2_springcloud