# 😎 知识点概览
> 为了方便后续回顾该项目时能够清晰的知道本章节讲了哪些内容,并且能够从该章节的笔记中得到一些帮助,所以在完成本章节的学习后在此对本章节所涉及到的知识点进行总结概述。
本章节为【学成在线】项目的 `day16` 的内容
- [x] 学习 Spring Security + Oauth2 基本概念以及实现过程。
- [x] 学习 `Oauth2` 的基本应用场景,这里主要是通过 `Oauth2` 的密码模式来实战。
- [x] 初识 `JWT` 令牌。
- [x] 本章节的最后通过 `Spring Security Oauth2` 完成了认证服务的基本实现,但授权还没做。
# 目录
内容会比较多,小伙伴们可以根据目录进行按需查阅。
[TOC]
# 一、用户需求分析
## 0x01 用户认证与授权
截至目前,项目已经完成了在线学习功能,用户通过在线学习页面点播视频进行学习。
如何去记录学生的学习过程呢?
要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程;如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。
什么是用户身份认证?
用户身份认证即用户去访问 `系统资源` 时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证表现形式有:用户名密码登录,指纹打卡等方式。
什么是用户授权?
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的 `权限`,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。
## 0x02 单点登录需求
本项目包括多个子项目,如:学习系统,教学管理中心、系统管理中心等,为了提高用户体验性需要实现用户只认证一次便可以在多个拥有访问权限的系统中访问,这个功能叫做单点登录。
引用百度百科:单点登录(Single Sign On),简称为 `SSO`,是目前比较流行的企业业务整合的解决方案之一。
`SSO` 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
下图是 `SSO` 的示意图,用户登录学成网一次即可访问多个系统。

## 0x03 第三方认证需求
作为互联网项目难免需要访问外部系统的资源,同样本 `服务` 也要访问 `其他服务` 的资源接口。
一个微信用户没有在学成在线注册,本系统可以通过请求微信系统来验证该用户的身份,验证通过后该用户便可在本系统学习,它的基本流程如下:

从上图可以看出,微信不属于本系统,本系统并没有存储微信用户的账号、密码等信息,本系统如果要获取该用户的基本信息则需要首先通过微信的认证系统(微信认证)进行认证,微信认证通过后本系统便可获取该微信用户的基本信息,从而在本系统将该微信用户的头像、昵称等信息显示出来,该用户便不用在本系统注册却可以直接学习。
什么是第三方认证(跨平台认证)?
当需要访问第三方系统的资源时需要首先通过第三方系统的认证(例如:微信认证),由第三方系统对用户认证通过,并授权资源的访问权限。

# 二、用户认证技术方案
## 0x01 单点登录技术方案
分布式系统要实现单点登录,通常将认证系统独立抽取出来,并且将用户身份信息存储在单独的存储介质,比如:`MySQL`、`Redis`,考虑性能要求,通常存储在 `Redis` 中,如下图:

单点登录的特点是:
1、认证系统为独立的系统。
2、各个 `子系统` 通过 `Http` 或其它协议与认证系统通信,完成用户认证。
3、用户身份信息存储在 `Redis` 集群。
Java 中有很多用户认证的框架都可以实现单点登录:
1、Apache Shiro.
2、CAS
3、Spring security CAS
## 0x02 Oauth2认证
### 认证流程
第三方认证技术方案最主要是解决认证协议的通用标准 问题,因为要实现 跨系统认证,各系统之间要遵循一定的接口协议。
`OAUTH` 协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用 `OAUTH` 认证服务,任何服务提供商都可以实现自身的 `OAUTH` 认证服务,因而 `OAUTH` 是开放的。业界提供了 `OAUTH` 的多种实现如 `PHP`、`JavaScript`,`Java`,`Ruby` 等各种语言开发包,大大节约了程序员的时间,因而 `OAUTH` 是简易的。互联网很多服务如 `Open API`,很多大公司如 `Google`,`Yahoo`,`Microsoft` 等都提供了 `OAUTH` 认证服务,这些都足以说明 `OAUTH` 标准逐渐成为开放资源授权的标准。
`Oauth` 协议目前发展到 `2.0` 版本,`1.0` 版本过于复杂,`2.0` 版本已得到广泛应用。
参考:https://baike.baidu.com/item/oAuth/7153134?fr=aladdin
Oauth协议:https://tools.ietf.org/html/rfc6749
下边分析一个Oauth2认证的例子,黑马程序员网站使用微信认证的过程:

从流程图可以看出,用户首先需要访问黑马程序员的登录页面,登录页面中会有一个第三方登录的选项,例如选择微信来进行登录。
点击微信登录后,黑马程序员网站会向微信获取到一个认证授权的页面,并返回给客户端,客户端自动跳转到该 `认证授权页面` 进行微信的认证,当用户通过微信授权认证成功后,微信的认证服务器会返回一个授权码到客户端,客户端使用授权码向微信认证服务器申请 认证`token`,当用户获取到 `token` 后,会携带该 `token` 值去请求黑马程序员网站,黑马程序员通过该token向微信服务器获取到用户的微信信息后,黑马程序员网站才能确定该用户是可信的。
具体流程演示如下:
1、客户端请求第三方授权
用户进入黑马程序的登录页面,点击微信的图标以微信账号登录系统,用户是自己在微信里信息的资源拥有者。

点击“微信”出现一个二维码,此时用户扫描二维码,开始给黑马程序员授权。

2、资源拥有者同意给客户端授权
资源拥有者扫描二维码表示资源拥有者同意给客户端授权,微信会对资源拥有者的身份进行验证, 验证通过后,微信会询问用户是否给授权黑马程序员访问自己的微信数据,用户点击 “确认登录”表示同意授权,微信认证服务器会颁发一个授权码,并重定向到黑马程序员的网站。

3、客户端获取到授权码,请求认证服务器申请令牌
此过程用户看不到,客户端应用程序请求认证服务器,请求携带授权码。
4、认证服务器向客户端响应令牌
认证服务器验证了客户端请求的授权码,如果合法则给客户端颁发令牌,令牌是客户端访问资源的通行证。此交互过程用户看不到,当客户端拿到令牌后,用户在黑马程序员看到已经登录成功。
5、客户端请求资源服务器的资源
客户端携带令牌访问资源服务器的资源。黑马程序员网站携带令牌请求访问微信服务器获取用户的基本信息。
6、资源服务器返回受保护资源
资源服务器校验令牌的合法性,如果合法则向用户响应资源信息内容。
注意:资源服务器和认证服务器可以是一个服务也可以分开的服务,如果是分开的服务资源服务器通常要请求认证服务器来校验令牌的合法性。
`Oauth2.0` 认证流程如下:
引自 `Oauth2.0` 协议 `rfc6749` https://tools.ietf.org/html/rfc6749

Oauth2包括以下角色:
1、客户端
本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:学成在线Android客户端、学成在线Web客户端(浏览器端)、微信客户端等。
2、资源拥有者
通常为用户,也可以是应用程序,即该资源的拥有者。
3、授权服务器(也称认证服务器)
用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授权后方可访问。
4、资源服务器
存储资源的服务器,比如,学成网用户管理服务器存储了学成网的用户信息,学成网学习服务器存储了学生的学习信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。
### Oauth2在本项目的应用
Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用 `Oauth2`,本项目使用 `Oauth2` 实现如下目标:
1、学成在线访问第三方系统的资源
2、外部系统访问学成在线的资源
3、学成在线前端(客户端) 访问学成在线微服务的资源。
4、学成在线微服务之间访问资源,例如:`微服务A` 访问 `微服务B` 的资源,`B` 访问 `A` 的资源。
## 0x03 Spring Security Oauth2 认证解决方案
本项目采用 `Spring security + Oauth2` 完成用户认证及用户授权,`Spring security` 是一个强大的和高度可定制的身份验证和访问控制框架,`Spring security` 框架集成了`Oauth2` 协议,下图是项目认证架构图:

1、用户请求认证服务完成认证。
2、认证服务下发用户身份令牌,拥有身份令牌表示身份合法。
3、用户携带令牌请求资源服务,请求资源服务必先经过网关。
4、网关校验用户身份令牌的合法,不合法表示用户没有登录,如果合法则放行继续访问。
5、资源服务获取令牌,根据令牌完成授权。
6、资源服务完成授权则响应资源信息。
# 三、Spring Security Oauth2 研究
## 0x01 目标
本项目认证服务基于 `Spring Security Oauth2` 进行构建,并在其基础上作了一些扩展,采用 `JWT` 令牌机制,并自定义了用户身份信息的内容。 本教程的主要目标是学习在项目中集成`Spring Security Oauth2` 的方法和流程,通过 `Spring Security Oauth2` 的研究需要达到以下目标:
1、理解 `Oauth2` 的授权码认证流程及密码认证的流程。
2、理解 `Spring Security Oauth2` 的工作流程。
3、掌握资源服务集成 `Spring Security` 框架完成 `Oauth2` 认证的流程。
## 0x02 搭建认证服务器
### 导入基础工程
导入 `资料` 目录下的 `xc-service-ucenter-auth` 工程,该工程是基于`Spring Security Oauth2` 的一个二次封装的工程,导入此工程研究 `Oauth2` 认证流程。
### 创建数据库
导入资料目录下的 `xc_user.sql`,创建用户数据库

以 `oauth_` 开头的表都是 `Spring Security` 自带的表。
本项目中 Spring Security 主要使用 `oauth_client_details` 表:

- client_id:客户端id
- resource_ids:资源id(暂时不用)
- client_secret:客户端密码
- scope:范围
- access_token_validity:访问token的有效期(秒)
- refresh_token_validity:刷新token的有效期(秒)
- authorized_grant_type:授权类型,
- authorization_code
- password
- refresh_token
- client_credentials
## 0x03 Oauth2授权码模式
Oauth2 有以下授权模式:
- 授权码模式(Authorization Code)
- 隐式授权模式(Implicit)
- 密码模式(Resource Owner PasswordCredentials)
- 客户端模式(Client Credentials)
其中授权码模式和密码模式应用较多,本小节介绍授权码模式。
### 授权码授权流程
上边例举的黑马程序员网站使用微信认证的过程就是授权码模式,流程如下:
1、客户端请求第三方授权
2、用户(资源拥有者)同意给客户端授权
3、客户端获取到授权码,请求认证服务器申请令牌
4、认证服务器向客户端响应令牌
5、客户端请求资源服务器的资源,资源服务校验令牌合法性,完成授权
6、资源服务器返回受保护资源
### 申请授权码
请求认证服务获取授权码:
GET 请求:
```
localhost:40400/auth/oauth/authorize?
client_id=XcWebApp&response_type=code&scop=app&redirect_uri=http://localhost
```
参数列表如下:
- client_id:客户端 `id`,和授权配置类中设置的客户端id一致。
- response_type:授权码模式固定为 `code`
- scop:客户端范围,和授权配置类中设置的 `scop `一致。
- redirect_uri:跳转 `uri`,当授权码申请成功后会跳转到此地址,并在后边带上code参(授权码)。
首次访问会跳转到登录页面:

输入账号和密码,点击 Login。
`Spring Security` 接收到请求会调用 `UserDetailsService` 接口的 `loadUserByUsername` 方法查询用户正确的密码。
在 `oauth_client_details` 表中配置认证的账号和密码,当然密码是加密后储存的,这里我们暂时先不关注,后面再讲解

账号密码为 `XcWebApp` 和 `XcWebApp`
接下来进入授权页面:

点击 同意,接下来返回授权码:认证服务携带授权码跳转 `redirect_uri`

### 申请令牌
拿到授权码后,申请令牌。
POST 请求:http://localhost:40400/auth/oauth/token
参数如下:
- grant_type:授权类型,填写authorization_code,表示授权码模式
- code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
- redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
此链接需要使用 http Basic认证。
什么是 `http Basic`认证?
`http` 协议定义的一种认证方式,将客户端id和客户端密码按照 `客户端ID:客户端密码` 的格式拼接,并用 `base64` 编码,放在 `header` 中请求服务端,一个例子:
Authorization:Basic WGNXZWJBcHA6WGNXZWJBcHA=
WGNXZWJBcHA6WGNXZWJBcHA= 是 `用户名:密码` 的 base64 编码。
如果认证失败服务端会返回 `401 Unauthorized`
以上测试使用 `postman` 完成。
http basic认证:

客户端 `Id` 和客户端密码会匹配数据库 `oauth_client_details` 表中的客户端 `id` 及客户端密码。
POST 请求参数:

点击发送:

申请令牌成功。
- access_token:访问令牌,携带此令牌访问资源
- token_type:有 `MAC Token`与 `Bearer Token`两种类型,两种的校验算法不同,RFC 6750建议Oauth2采用 Bearer
- Token(http://www.rfcreader.com/#rfc6750)。
- refresh_token:刷新令牌,使用此令牌可以延长访问令牌的过期时间。
- expires_in:过期时间,单位为秒。
- scope:范围,与定义的客户端范围一致。
### 资源服务授权
#### 1)授权流程
资源服务拥有要访问的受保护资源,客户端携带令牌访问资源服务,如果令牌合法则可成功访问资源服务中的资源,流程如下图:

上图的业务流程如下:
1、客户端请求认证服务申请令牌
2、认证服务生成令牌认证服务采用非对称加密算法,使用私钥生成令牌。
3、客户端携带令牌访问资源服务客户端在 `Http header` 中添加: `Authorization:Bearer 令牌`。
> 注意这里的Authorization字段的值为 Bearer + 空格 + 令牌
4、资源服务请求认证服务校验令牌的有效性资源服务接收到令牌,使用公钥校验令牌的合法性。
5、令牌有效,资源服务向客户端响应资源信息
#### 2)授权配置
基本上所有微服务都是资源服务,这里我们在 `课程管理服务` 上配置授权控制,当配置了授权控制后如要访问课程信息则必须提供令牌。
在我们导入的 `auth` 工程的 `resources` 下可以看到一个 `xc.keystore` 文件,该文件是用于认证的一个私钥文件,用于生成我们的授权码,生成的授权码可以使用 `公钥` 文件来进行校验。下面我们来做一个简单的实验来了解整个校验的流程。
1、配置公钥
认证服务生成令牌采用非对称加密算法,认证服务采用私钥加密生成令牌,对外向资源服务提供公钥,资源服务使
用公钥 来校验令牌的合法性。
将 `day16` 的 `资料` 下的公钥拷贝到 `publickey.txt` 文件中,将此文件拷贝到资源服务工程的 `classpath` 下

2、添加依赖
```xml
<!--oatuh2-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
```
4、在 `config` 包下创建 ResourceServerConfig 类:
```java
package com.xuecheng.manage_course.config;
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "publickey.txt";
//定义JwtTokenStore,使用jwt令牌
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
//定义JJwtAccessTokenConverter,使用jwt令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
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;
}
}
//Http安全配置,对每个到达系统的http请求链接进行校验
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests().anyRequest().authenticated();
}
}
```
#### 3)授权测试
这里我们使用 `POSTMAN` 测试课程图片查询
GET http://localhost:31200/course/coursepic/list/4028e58161bd3b380161bd3bcd2f0000
请求时没有携带令牌则报错:
```
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
```
请求时携带令牌:
在 `http header` 中添加 `Authorization: Bearer` 令牌

当输入错误的令牌也无法正常访问资源。

#### 4)解决swagger-ui无法访问
> 这个问题可以单独提取出来,发布到csdn上。
当课程管理加了授权之后再访问 `swagger-ui` 则报错

修改授权配置类 `ResourceServerConfig` 的 `configure` 方法:
针对 `swagger-ui` 的请求路径进行放行:
```java
//Http安全配置,对每个到达系统的http请求链接进行校验
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers("/v2/api-docs", "/swagger-resources/configuration/ui",
"/swagger-resources","/swagger-resources/configuration/security",
"/swagger-ui.html","/webjars/**").permitAll()
.anyRequest().authenticated();
}
```
注意:
通过上边的配置虽然可以访问 `swagger-ui`,但是无法进行单元测试,除非去掉认证的配置或在上边配置中添加所有请求均放行("/**")。
但是需要注意的是,虽说在开发环境下我们可与通过使用 `/**` 的方式来便于我们进行单元测试,但是难免会有疏漏的时候,如果在生产上线时没有及时改回来,那么后果不堪设想。
所以我们可以考虑使用多环境配置的形式,将需要放行的 `url` 从配置文件 `application.yml` 中读取,而开发环境中,我们可以单独配置一个 `application-dev.yml` 作为我们的开发环境的配置,用于区别上线环境的配置。例如下面的例子
`application-dev.yml` 加入 `oauth2.urlMatchers` ,在该字段下指定我们无需授权访问的一些url地址,使用 `,` 进行分割
```yml
oauth2:
urlMatchers: /v2/api-docs,/swagger-resources/configuration/ui,/swagger-resources,/swagger-resources/configuration/security,/swagger-ui.html,/webjars/**
```
而在生产环境中的配置 `application-dev.yml` 你可以不配置 urlMatchers 的值,这样所有的url都需要认证后才能访问,当然,你也可以添加一些例外。如下则是全部 `url` 都拦截的情况的配置,虽然没有值,但是我们也要在配置文件中写出这个 `urlMatchers` 字段,便于后面的拓展工作。
```
oauth2:
urlMatchers:
```
那么我们的配置类 `ResourceServerConfig` 就应该做出如下的修改:
使用 `@Value` 注解获取配置文件中 `urlMatchers` 的值,在 `configure` 方法下做出相关的操作。
```java
@Value("${oauth2.urlMatchers}")
String urlMatchers;
//Http安全配置,对每个到达系统的http请求链接进行校验
@Override
public void configure(HttpSecurity http) throws Exception {
if(urlMatchers.equals("")){
//如果urlMatchers未指定,则所有url都需要授权后才能被访问
http.authorizeRequests().anyRequest().authenticated();
}else{
//放行 urlMatchers 中指定的url条目, 未指定的url仍需授权后才能访问
String[] split = urlMatchers.split(",");
http.authorizeRequests()
//下边的路径放行
.antMatchers(split).permitAll()
.anyRequest().authenticated();
}
}
```
## 0x04 Oauth2密码模式认证
密码模式(Resource Owner Password Credentials)与授权码模式的区别是申请令牌不再使用授权码,而是直接
通过用户名和密码即可申请令牌。
测试如下:
POST 请求:http://localhost:40400/auth/oauth/token
参数:
- grant_type:密码模式授权填写password
- username:账号
- password:密码
那么这个密码信息是从哪里获取到的?
在我们的 auth 服务工程中可以看到,我们写了一个 `UserDetailsService` 的实现类。
这个实现类中实现了 `loadUserByUsername` 方法,在该方法中,首先会验证提交请求中带有的 App 用户密码信息是否正确,也就是我们提交的 `http Basic` 认证信息,App的认证信息通过后,会出数据库获取用户的认证信息和权限信息进行设置,然后再根据我们提交的信息进行比对。但在当前的测试中,我们是直接在 `loadUserByUsername` 方法内自定义了一个账号和密码,便于我们测试,完整的认证授权流程会在后面的内容中讲到。
```java
//设置用户的认证和权限信息
XcUserExt userext = new XcUserExt();
userext.setUsername("mrt");
userext.setPassword(new BCryptPasswordEncoder().encode("123"));
userext.setPermissions(new ArrayList<XcMenu>());
if(userext == null){
return null;
}
```
使用 `http Basic ` 进行App的身份认证,这里我们的账号和密码都为 `XcWebApp`。

上边参数使用 `x-www-form-urlencoded` 方式传输,使用postman测试如下:

那么,授权码模式与密码模式,分别都适用于哪些场景?
- 授权码模式一般适用于提供给第三方进行认证,例如在前面提到的在黑马程序员网站进行微信登录时,这里我们的角色就应该对应的是 `微信的认证服务器`,而黑马程序员网站属于第三方。
- 密码模式在我们后续的开发中会经常用到,一般用于我们微服务间的认证以及用户的前台、后台权限管理等场景。
## 0x05 校验令牌
Spring Security Oauth2 提供校验令牌的端点,如下:
GET: http://localhost:40400/auth/oauth/check_token?token=
参数:
- token:令牌
使用 `postman` 测试如下:

结果如下:
```json
{
"companyId": null,
"userpic": null,
"user_name": "itcast",
"scope": [
"app"
],
"name": null,
"utype": null,
"active": true,
"id": null,
"exp": 1590351690,
"jti": "ed441eb3-cd16-4e74-b598-484656a03287",
"client_id": "XcWebApp"
}
```
- exp:过期时间,long类型,距离1970年的秒数(new Date().getTime()可得到当前时间距离1970年的毫秒数)。
- user_name: 用户名
- client_id:客户端Id,在oauth_client_details中配置
- scope:客户端范围,在oauth_client_details表中配置
- jti:与令牌对应的唯一标识
- companyId、userpic、name、utype、id:这些字段是本认证服务在Spring Security基础上扩展的用户身份信息
我们可以根据这些数据进行一些相关的操作。
## 0x06 刷新令牌
刷新令牌是当令牌快过期时重新生成一个令牌,它于授权码授权和密码授权生成令牌不同,刷新令牌不需要授权码
也不需要账号和密码,只需要一个 `刷新令牌`、`客户端id` 和 `客户端密码`。
测试如下:
POST:http://localhost:40400/auth/oauth/token
参数:
- grant_type: 固定为 refresh_token
- refresh_token:刷新令牌(注意不是 `access_token`,而是 `refresh_token`)
刷新令牌成功,会重生成新的访问令牌和刷新令牌,令牌的有效期也比旧令牌长。
刷新令牌通常是在令牌快过期时进行刷新。

## 0x07 JWT研究
### JWT介绍
在介绍JWT之前先看一下传统校验令牌的方法,如下图:

问题:
传统授权方法的问题是用户每次请求资源服务,资源服务都需要携带令牌访问认证服务去校验令牌的合法性,并根
据令牌获取用户的相关信息,性能低下。
解决:
使用 `JWT` 的思路是,用户认证通过会得到一个 `JWT` 令牌,`JWT` 令牌中已经包括了用户相关的信息,客户端只需要携带 `JWT` 访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。`JWT` 令牌授权过程如下图:

什么是 `JWT` ?
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于
在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。`JWT` 可以使用 `HMAC` 算法或使用 `RSA`的公钥/私钥对来签名,防止被篡改。
官网:https://jwt.io/
标准:https://tools.ietf.org/html/rfc7519
`JWT` 令牌的优点:
1、jwt基于 `json`,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过非对称加密算法及数字签名技术,`JWT` 防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:JWT令牌较长,占存储空间比较大。
#### 令牌结构
通过学习JWT令牌结构为自定义 `jwt` 令牌打好基础。
JWT 令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
**1、Header**
头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
下边是Header部分的内容
```json
{
"alg": "HS256",
"typ": "JWT"
}
```
**2、Payload**
第二部分是用于储存一些有效信息,内容也是一个 `json `对象,它可以存放 `jwt` 提供的现成字段,比如:`iss`(签发者),`exp`(过期时间戳), `sub`(面向的用户)等,也可自定义字段。
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
最后将第二部分负载使用 `Base64Url` 编码,得到一个字符串就是JWT令牌的第二部分。
一个例子 :
```json
{
"sub": "1234567890",
"name": "456",
"admin": true
}
```
**3、Signature**
第三部分是签名,此部分用于防止jwt内容被篡改。
这个部分使用 `base64url` 将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明
签名算法进行签名。
一个例子:
```
HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
```
- base64UrlEncode(header):jwt令牌的第一部分。
- base64UrlEncode(payload):jwt令牌的第二部分。
- secret:签名所使用的密钥。
![]()
### JWT入门
Spring Security 提供对 `JWT` 的支持,本节我们使用 `Spring Security` 提供的 `JwtHelper` 来创建JWT令牌,校验JWT令牌等操作。
#### 生成私钥和公钥
JWT令牌生成采用非对称加密算法
1、生成密钥证书
下边命令生成密钥证书,采用RSA 算法每个证书包含公钥和私钥
```powershell
keytool -genkeypair -alias xckey -keyalg RSA -keypass xuecheng -keystore xc.keystore -storepass xuechengkeystore
```
`Keytool` 是一个 `java` 提供的证书管理工具,以下是参数的说明
- -alias:密钥的别名
- -keyalg:使用的hash算法
- -keypass:密钥的访问密码
- -keystore:密钥库文件名,xc.keystore保存了生成的证书
- -storepass:密钥库的访问密码
查询证书信息:
```
keytool -list -keystore xc.keystore
```

删除别名:
```
keytool -delete -alias xckey -keystore xc.keystore
```
2、导出公钥
`openssl` 是一个加解密工具包,这里使用openssl来导出公钥信息。
安装 openssl:http://slproweb.com/products/Win32OpenSSL.html
安装完成后,配置 `openssl` 的 `path` 环境变量,本教程配置在 `D:\OpenSSL-Win64\bin`
在命令行进入 `xc.keystore` 文件所在目录执行如下命令:
```
keytool -list -rfc --keystore xc.keystore | openssl x509 -inform pem -pubkey
```
输入密钥库密码,如下图:

复制生成出来的公钥数据

将上边的公钥拷贝到文本文件中,`合并为一行`,换行会有换行符,所以尽可能的避免一些我的发生 ,可以用`notepad++` 直接替换换行符 `\n` 如下图,当然你也可以手动的合并成一行。

```
-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi1pGvYqLcTG2dcKhrtisQgkB90iWaCwE4OriDwCLMdiUV2NViEn+r/jMbuIFCBtnB21yWZlIPnXjzcre/8HIUJy2dMWqP9NUhzoCzwdC1I9clZRVHTpe1H0eiaQY4BLxz5EScBZdr5u4Q0hT+t6D3t7qQg1MHxLBaFy2cdHQbmz5Ly/1mmnWBHmFgjbbNG7gfaO3jRCl7RbNVUfSjb6gN+MfpyLk/iXr5S8Qhc2X07hvtm09QEk3cl14tQkZkXAUk7rAl9kgPSKoKr4MAdiYEsVNplKd4LMs4S2AC0dYrhdIX754eo6u4Ehpe6v5hSsF2d3ZpuV7nJ6JDCNxo7tU9wIDAQAB-----END PUBLIC KEY-----
```
#### 生成jwt令牌
在认证工程创建测试类,测试jwt令牌的生成与验证。
```java
package com.xuecheng.auth;
import com.alibaba.fastjson.JSON;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.jwt.Jwt;
import org.springframework.security.jwt.JwtHelper;
import org.springframework.security.jwt.crypto.sign.RsaSigner;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;
import org.springframework.test.context.junit4.SpringRunner;
import java.security.KeyPair;
import java.security.interfaces.RSAPrivateKey;
import java.util.HashMap;
import java.util.Map;
@SpringBootTest
@RunWith(SpringRunner.class)
public class JwtTest {
//生成一个jwt令牌
@Test
public void testCreateJwt(){
//证书文件
String key_location = "xc.keystore";
//密钥库密码
String keystore_password = "xuechengkeystore";
//访问证书路径
ClassPathResource resource = new ClassPathResource(key_location);
//密钥工厂
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(resource, keystore_password.toCharArray());
//密钥的密码,此密码和别名要匹配
String keypassword = "xuecheng";
//密钥别名
String alias = "xckey";
//密钥对(密钥和公钥)
KeyPair keyPair = keyStoreKeyFactory.getKeyPair(alias,keypassword.toCharArray());
//私钥
RSAPrivateKey aPrivate = (RSAPrivateKey) keyPair.getPrivate();
//定义payload信息
Map<String, Object> tokenMap = new HashMap<>();
tokenMap.put("id", "123");
tokenMap.put("name", "mrt");
tokenMap.put("roles", "r01,r02");
tokenMap.put("ext", "1");
//生成jwt令牌
Jwt jwt = JwtHelper.encode(JSON.toJSONString(tokenMap), new RsaSigner(aPrivate));
//取出jwt令牌
String token = jwt.getEncoded();
System.out.println(token);
}
}
```
#### 验证jwt令牌
```java
//资源服务使用公钥验证jwt的合法性,并对jwt解码
@Test
public void testVerify(){
//jwt令牌
String token ="";
//公钥
String publickey = "";
//校验jwt
Jwt jwt = JwtHelper.decodeAndVerify(token, new RsaVerifier(publickey));
//获取jwt原始内容
String claims = jwt.getClaims();
System.out.println(claims);
//jwt令牌
String encoded = jwt.getEncoded();
System.out.println(encoded);
}
```
# 四、认证服务开发
## 0x01 需求分析
用户登录的流程图如下

执行流程:
1、用户登录,请求认证服务
2、认证服务认证通过,生成 `jwt` 令牌,将 `jwt` 令牌及相关信息写入 `Redis`,并且将身份令牌写入 `cookie`
3、用户访问资源页面,带着 `cookie` 到网关
4、网关从 `cookie` 获取 `token`,并查询 `Redis` 校验 `token`,如果 `token` 不存在则拒绝访问,否则放行
5、用户退出,请求认证服务,清除 `redis` 中的 `token`,并且删除 `cookie` 中的 `token`
使用 `redis` 存储用户的身份令牌有以下作用:
1、实现用户退出注销功能,服务端清除令牌后,即使客户端请求携带 `token` 也是无效的。
2、由于 `jwt` 令牌过长,不宜存储在 cookie 中,所以将 jwt 的 `身份令牌` 存储在 `redis`,客户端请求服务端时附带这个 `身份令牌`,服务端根据身份令牌到 `redis` 中取出身份令牌对应的 jwt 令牌。
## 0x02 Redis配置
### 安装Redis服务
下载Windows版本的redis:https://github.com/MicrosoftArchive/redis/tags
下载 `msi` 安装包进行安装
刷新服务,会看到多了一个 `redis` 服务。

如果下载的是zip包
运行
```
redis‐server redis.windows.conf
```
注册为服务:
```
redis‐server ‐‐service‐install redis.windows‐service.conf ‐‐loglevel verbose
```
常用的 redis 服务命令如下:
卸载服务:`sc delete Redis`
开启服务:`net start Redis`
停止服务:`net stop Redis`
下载 windows 版本的redis客户端:https://redisdesktop.com/download
下载 redis-desktop-manager-0.9.2.806.exe
安装后启动 `redis` 客户端:
配置 `redis` 链接:

连接成功

### redis连接配置
在认证服务的 `application.yml` 文件中添加如下配置:
```yml
spring:
application:
name: xc‐service‐ucenter‐auth
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6379}
timeout: 5000 #连接超时 毫秒
jedis:
pool:
maxActive: 3
maxIdle: 3
minIdle: 1
maxWait: ‐1 #连接池最大等行时间 ‐1没有限制
```
### 测试
```java
@SpringBootTest
@RunWith(SpringRunner.class)
public class RedisTest {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Test
public void testRedis(){
//定义key
String key = "user_token:9734b68f‐cf5e‐456f‐9bd6‐df578c711390";
//定义Map
Map<String,String> mapValue = new HashMap<>();
mapValue.put("id","101");
mapValue.put("username","itcast");
String value = JSON.toJSONString(mapValue);
//向redis中存储字符串
stringRedisTemplate.boundValueOps(key).set(value,60, TimeUnit.SECONDS);
//读取过期时间,已过期返回‐2
Long expire = stringRedisTemplate.getExpire(key);
//根据key获取value
String s = stringRedisTemplate.opsForValue().get(key);
System.out.println(s);
}
}
```
## 0x03 认证服务
### 需求分析
认证服务需要实现的功能如下:
1、登录接口
前端post提交账号、密码等,用户身份校验通过,生成令牌,并将令牌存储到redis。
将令牌写入cookie。
2、退出接口
校验当前用户的身份为合法并且为已登录状态。
将令牌从redis删除。
删除cookie中的令牌。
业务流程如下:

### Api接口
```java
@Api(value = "用户认证",description = "用户认证接口")
public interface AuthControllerApi {
@ApiOperation("登录")
public LoginResult login(LoginRequest loginRequest);
@ApiOperation("退出")
public ResponseResult logout();
}
```
### 配置参数
在 `application.yml` 中配置参数
```yml
auth:
tokenValiditySeconds: 1200 #token存储到redis的过期时间
clientId: XcWebApp
clientSecret: XcWebApp
cookieDomain: localhost
cookieMaxAge: ‐1
```
### 申请令牌测试
为了不破坏 `Spring Security` 的代码,我们在 `Service` 方法中通过 `RestTemplate` 请求 `Spring Security` 所暴露的申请令
牌接口来申请令牌,下边是测试代码:
```java
@SpringBootTest
@RunWith(SpringRunner.class)
public class TestClient {
//Eureka负载均衡客户端
@Autowired
LoadBalancerClient loadBalancerClient;
@Autowired
RestTemplate restTemplate;
@Test
public void testClient(){
//采用客户端负载均衡的方式从eureka获取认证服务的ip和端口
ServiceInstance serviceInstance = loadBalancerClient.choose("XC-SERVICE-UCENTER-AUTH");
URI uri = serviceInstance.getUri();
String authUrl = uri + "/auth/oauth/token";
//使用LinkedMultiValueMap储存多个header信息
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
//设置basic认证信息
String basicAuth = this.getHttpBasic("XcWebApp", "XcWebApp");
headers.add("Authorization",basicAuth);
//设置请求中的body信息
LinkedMultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type","password");
body.add("username","itcast");
body.add("password","12322");
HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(body, headers);
//凭证信息错误时候, 指定restTemplate当遇到400或401响应时候也不要抛出异常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException{
//当响应的值为400或者401时也要正常响应,不要抛出异常
if(response.getRawStatusCode()!=400 && response.getRawStatusCode()!=401){
super.handleError(response);
}
}
});
//远程调用令牌
ResponseEntity<Map> responseEntity = restTemplate.exchange(authUrl, HttpMethod.POST, httpEntity, Map.class);
Map responseBody = responseEntity.getBody();
System.out.println(responseBody);
}
private String getHttpBasic(String clientId,String clientSecret){
//将客户端id和客户端密码拼接,按“客户端id:客户端密码”
String string = clientId+":"+clientSecret;
//进行base64编码
byte[] encode = Base64.encode(string.getBytes());
return "Basic "+new String(encode);
}
}
```
### Dao
暂时使用静态数据,待用户登录调通再连接数据库校验用户信息。
### Service
调用认证服务申请令牌,并将令牌存储到 `redis`。
1、AuthToken
创建 `AuthToken` 模型类,存储申请的令牌,包括身份令牌、刷新令牌、jwt令牌
身份令牌:用于校验用户是否认证
刷新令牌:jwt令牌快过期时执行刷新令牌
jwt令牌:用于授权
```java
package com.xuecheng.framework.domain.ucenter.ext;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* Created by mrt on 2018/5/21.
*/
@Data
@ToString
@NoArgsConstructor
public class AuthToken {
String access_token;//访问token
String refresh_token;//刷新token
String jwt_token;//jwt令牌
}
```
**申请令牌的 service 方法如下**
这里要注意一点的就是,原视频和讲义中将用户凭证储存到 `redis` 的方法是直接储存 `access_token` 作为 `key`,如果 access_token 作为key储存的话,用户在发送认证请求的时候就需要提供 `access_token`,也意味着 access_token 需要暴露给用户。
- 直接暴露 access_token 会有一定的安全风险
- access_token 长度太大,不适合储存在cookie
前面的时候该课程的老师也讲到了这个问题,但是后面还是犯了这个错误(至少我认为这是不应该的),所以 key 应该使用 `jti` 的值作为储存 ,value 为 `access_token` + `refresh_token` 储存到 `map` 转换成`json`后的字符串。
```java
package com.xuecheng.auth.service.impl;
@Service
public class AuthServiceImpl implements AuthService {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthService.class);
@Value("${auth.tokenValiditySeconds}")
int tokenValiditySeconds;
//Eureka负载均衡客户端
@Autowired
LoadBalancerClient loadBalancerClient;
@Autowired
RestTemplate restTemplate;
@Autowired
StringRedisTemplate stringRedisTemplate;
/**
* 用户登陆认证实现
* @param username 用户名
* @param password 密码
* @param clientId 客户端id
* @param clientSecret 客户端凭证
* @return AuthToken
*/
@Override
public AuthToken login(String username, String password, String clientId, String clientSecret) {
//申请令牌
AuthToken authToken = this.appleToken(username, password, clientId, clientSecret);
if(authToken == null){
ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
}
//保存令牌到redis
boolean saveToken = this.saveToken(authToken, tokenValiditySeconds);
if(!saveToken){
ExceptionCast.cast(AuthCode.AUTH_LOGIN_TOKEN_SAVEFAIL);
}
return authToken;
}
//储存令牌到redis
private boolean saveToken(AuthToken authToken, long ttl){
//储存到redis的key
String key = "user_token:" + authToken.getJwt_token();
Map<String,String> valueMap = new HashMap<>();
//拼装value
valueMap.put("access_token",authToken.getAccess_token());
valueMap.put("refresh_token",authToken.getRefresh_token());
String valueJson = JSON.toJSONString(valueMap);
//保存到令牌到redis
stringRedisTemplate.boundValueOps(key).set(valueJson,ttl, TimeUnit.SECONDS);
//获取过期时间
Long expire = stringRedisTemplate.getExpire(key);
//大于0则返回true
return expire>0;
}
//向Oauth2服务申请令牌
private AuthToken appleToken(String username, String password, String clientId, String clientSecret){
//采用客户端负载均衡的方式从eureka获取认证服务的ip和端口
ServiceInstance serviceInstance = loadBalancerClient.choose("XC-SERVICE-UCENTER-AUTH");
URI uri = serviceInstance.getUri();
String authUrl = uri + "/auth/oauth/token";
//使用LinkedMultiValueMap储存多个header信息
LinkedMultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
//设置basic认证信息
String basicAuth = this.getHttpBasic(clientId, clientSecret);
headers.add("Authorization",basicAuth);
//设置请求中的body信息
LinkedMultiValueMap<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当遇到400或401响应时候也不要抛出异常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
//当响应的值为400或者401时也要正常响应,不要抛出异常
if(response.getRawStatusCode()!=400 && response.getRawStatusCode()!=401){
super.handleError(response);
}
}
});
Map map = null;
try {
//http请求spring security的申请令牌接口
ResponseEntity<Map> mapResponseEntity = restTemplate.exchange(authUrl, HttpMethod.POST,
new HttpEntity<MultiValueMap<String, String>>(body, headers), Map.class);
map = mapResponseEntity.getBody();
} catch (RestClientException e) {
e.printStackTrace();
LOGGER.error("request oauth_token_password error: {}",e.getMessage());
e.printStackTrace();
ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
}
//校验获取到的jwt是否完成
if(map == null ||
map.get("access_token") == null ||
map.get("refresh_token") == null ||
map.get("jti") == null){//jti是jwt令牌的唯一标识作为用户身份令牌
ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
}
//拼装authToken并返回
AuthToken authToken = new AuthToken();
//访问令牌(jwt)
String access_token = (String) map.get("access_token");
//刷新令牌(jwt)
String refresh_token = (String) map.get("refresh_token");
//jti,作为用户的身份标识,也就是后面我们用于返回给到用户前端的凭证
String jwt_token = (String) map.get("jti");
authToken.setAccess_token(access_token);
authToken.setRefresh_token(refresh_token);
authToken.setJwt_token(jwt_token);
return authToken;
}
private String getHttpBasic(String clientId, String clientSecret){
//将客户端id和客户端密码拼接,按“客户端id:客户端密码”
String string = clientId+":"+clientSecret;
//进行base64编码
byte[] encode = Base64.encode(string.getBytes());
return "Basic "+new String(encode);
}
}
```
### Controller
```java
package com.xuecheng.auth.controller;
//在配置文件中设置了context-path: /auth 所以这里我们就不用再配置RequestMapping
@RestController
public class AuthController implements AuthControllerApi {
//客户端认证信息
@Value("${auth.clientId}")
String clientId;
@Value("${auth.clientSecret}")
String clientSecret;
//cookie域
@Value("${auth.cookieDomain}")
String cookieDomain;
//cookie生命周期
@Value("${auth.cookieMaxAge}")
int cookieMaxAge;
//生命周期
@Value("${auth.tokenValiditySeconds}")
int tokenValiditySeconds;
@Autowired
AuthService authService;
/**
* 用户登陆接口
* @param loginRequest 登陆参数
* @return LoginResult
*/
@PostMapping("/userlogin")
@Override
public LoginResult login(LoginRequest loginRequest) {
//校验账号是否输入
if(loginRequest == null || StringUtils.isEmpty(loginRequest.getUsername())){
ExceptionCast.cast(AuthCode.AUTH_USERNAME_NONE);
}
//校验密码是否输入
if(StringUtils.isEmpty(loginRequest.getPassword())){
ExceptionCast.cast(AuthCode.AUTH_PASSWORD_NONE);
}
//获取用户token信息并且保存到redis内
AuthToken authToken = authService.login(
loginRequest.getUsername(),loginRequest.getPassword(), clientId, clientSecret);
//将用户token写入cookie
String jtw_token = authToken.getJwt_token();
//将访问令牌存储到cookie
this.saveCookie(jtw_token);
return new LoginResult(CommonCode.SUCCESS,jtw_token);
}
@Override
public ResponseResult logout() {
return null;
}
//将令牌保存到cookie
private void saveCookie(String token){
HttpServletResponse response = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getResponse();
//添加cookie 认证令牌,最后一个参数设置为false,表示允许浏览器获取
CookieUtil.addCookie(response, cookieDomain, "/", "uid", token, cookieMaxAge, false);
}
}
```
### 登录url放行
认证服务默认都要校验用户的身份信息,这里需要将登录url放行。
在 `WebSecurityConfig` 类中重写 configure(WebSecurity web)方法,如下:
```java
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/userlogin");
}
```
### 测试认证接口
使用 `postman` 测试:
POST 请求:http://localhost:40400/auth/userlogin

### 测试写入Cookie
cookie最终会写到 `xuecheng.com` 域名下,可通过 `nginx` 代理进行认证,测试cookie是否写成功。
**1、配置nginx代理**
在`ucenter.xuecheng.com`下配置代理路径
```c
#认证
location ^~ /openapi/auth/ {
proxy_pass http://auth_server_pool/auth/;
}
```
添加
```c
#认证服务
upstream auth_server_pool{
server 127.0.0.1:40400 weight=10;
}
```
2、检查我们的配置文件中`domain`的配置
domain 设置为我们学成的主站域名,`xuecheng.com`
```yml
auth:
tokenValiditySeconds: 1200 #token存储到redis的过期时间
clientId: XcWebApp
clientSecret: XcWebApp
cookieDomain: xuecheng.com
cookieMaxAge: -1
```
**3、请求测试**
http://ucenter.xuecheng.com/openapi/auth/userlogin
观察 `cookie` 写入结果

请求成功,cookie也成功拿到

# 五、一些需要注意的问题
## 通用工程的依赖继承的问题
`model` 工程中构建 UserJwt 实体时候需要引入 `oauth2` 的依赖,所以在引入依赖时需要注意使用 `optional` 标签防止其他服务工程继承到 model 工程下的 `oauth2` 依赖。
```xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<optional>true</optional>
</dependency>
```
如果不配置 `<optional>true</optional>` 会导致暂时无需认证的服务也会被动的开启了认证,并导致所有的接口都被认证拦截。
并且 model 、api 、common 这三个通用工程在引入本工程需要用到的一些依赖时,也务必加上`<optional>true</optional>` 这个标签,防止依赖继承导致的一些问题的出现。
# 😁 认识作者
作者:👦 LCyee ,一个向往体面生活的代码🐕
自建博客:[https://www.codeyee.com](https://www.codeyee.com)
> 记录学习以及项目开发过程中的笔记与心得,记录认知迭代的过程,分享想法与观点。
CSDN 博客:[https://blog.csdn.net/codeyee](https://blog.csdn.net/codeyee)
> 记录和分享一些开发过程中遇到的问题以及解决的思路。
欢迎加入微服务练习生的队伍,一起交流项目学习过程中的一些问题、分享学习心得等,不定期组织一起刷题、刷项目,共同见证成长。

![微服务[学成在线] day16:基于Spring Security Oauth2开发认证服务](https://qnoss.codeyee.com/TIM%E5%9B%BE%E7%89%8720200721182350_1595327150222.jpg)
微服务[学成在线] day16:基于Spring Security Oauth2开发认证服务