-
完整版请移步至我的个人博客查看:https://cyborg2077.github.io/
-
学成在线–项目环境搭建
-
学成在线–内容管理模块
-
学成在线–媒资管理模块
-
学成在线–课程发布模块
-
学成在线–认证授权模块
-
学成在线–选课学习模块
-
学成在线–项目优化
-
Git仓库:https://github.com/Cyborg2077/xuecheng-plus
模块需求分析
什么是认证授权
- 截至目前,项目已经完成了课程发布功能,课程发布后用户通过在线学习页面点播视频进行学习。如何去记录学生的学习过程呢?要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程,如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。
- 认证授权模块实现平台所有用户的身份认证和用户授权功能
{% note info no-icon %} - 什么是用户身份认证?
{% endnote%}
{% note pink no-icon %} - 用户身份认证即当用户访问系统资源时,系统要求验证用户的身份信息,身份合法方可继续访问
- 常见的用户身份认证表现形式有
- 用户名密码登录
- 微信扫码登录等
{% endnote %}
- 项目包括学生、学习机构的老师、平台运营人员三类用户。
- 不管哪一类用户在访问项目受保护的资源时,都需要进行身份认证,例如
- 发布课程操作:需要学习机构的老师首先登录系统成功,然后再执行发布课程操作
- 创建订单操作:需要学生用户首先登录系统成功,才可以创建订单
{% note info no-icon %}
- 什么是用户授权?
{% endnote %}
{% note pink no-icon %} - 用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。
- 例如用户去发布课程,系统首先进行用户身份认证,认证通过后继续判断用户是否有发布课程的权限
- 如果没有权限,则拒绝继续访问系统
- 如果有权限,则继续发布课程
{% endnote %}
业务流程
统一认证
- 项目包括学生、学习机构的老师、平台运营人员三类用户,三类用户将使用统一的认证入口
- 用户输入账号密码提交认证,认证通过后继续操作
- 认证通过由认证服务想用户颁发令牌,相当于访问系统的通行证,用户拿着令牌去访问系统的资源
单点登录
- 本项目基于微服务架构构建,微服务包括:内容管理服务、媒资管理服务、系统管理服务等。
- 为了提高用户的体验性,用户只需要依次认证,便可以在多个拥有访问权限的系统中访问,这个功能叫单点登录
{% note info no-icon %} - 单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
{% endnote %}
第三方认证
- 为了提高用户体验,很多网站都具有扫码登录的功能,例如微信扫码登录、QQ扫码登录等
- 扫码登录的好处是用户不用输入账号密码,操作简便,而且有利于用户信息的共享。
- 互联网的优势就是资源共享,用户也是一种资源,对于一个新网站,如果让用户去注册是很困难的,如果提供了微信扫码登录,将省去用户的注册成本,是一种非常有效的推广方式。
- 微信扫码登录其中的原理正是使用了第三方认证,如下图
Spring Security认证
Spring Security介绍
- 认证功能几乎是每个项目都要具备的功能,并且它与业务无关,市面上有很多认证框架,如Apache Shiro、CAS、Spring Security等
- 本项目是基于Spring Cloud技术构建,Spring Security是spring家族的一份子,且和Spring Cloud集成的很好,所以本项目采用Spring Security作为认证服务的技术框架
- Spring Security是一个功能强大且可高度定制的身份验证和访问控制框架,它是一个专注于为Java应用程序提供身份验证和授权的框架
- 项目主页:https://spring.io/projects/spring-security
- SpringCloud Security:https://spring.io/projects/spring-cloud-security
认证授权入门
-
下面我们使用Spring Security框架,快速构建认证授权功能体系
- 部署认证服务工程
- 拷贝黑马提供的xuecheng-plus-auth工程到自己的项目根目录下
- 此工程是一个普通的SpringBoot工程,可以连接数据库
- 此工程不具备认证授权功能
- 创建数据库
- 创建users数据库
- 导入黑马提供的xcplus_users.sql脚本
- 在nacos中新增auth-service-dev.yaml
server:servlet:context-path: /authport: 53070 spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/xc_users?serverTimezone=UTC&userUnicode=true&useSSL=false&username: rootpassword: mysql
- 部署认证服务工程
-
初识工程自带了一个Controller类,如下
@Slf4j
@RestController
public class LoginController {@AutowiredXcUserMapper userMapper;@RequestMapping("/login-success")public String loginSuccess() {return "登录成功";}@RequestMapping("/user/{id}")public XcUser getuser(@PathVariable("id") String id) {XcUser xcUser = userMapper.selectById(id);return xcUser;}@RequestMapping("/r/r1")public String r1() {return "访问r1资源";}@RequestMapping("/r/r2")public String r2() {return "访问r2资源";}
}
- 启动工程,访问
localhost:53070/auth/r/r1
,localhost:53070/auth/user/52
,可以访问到数据,则表明此工程部署成功
认证测试
- 下面向SpringBoot工程集成Spring Security
- 向pom.xml中加入Spring Security所需的依赖
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-security</artifactId></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-oauth2</artifactId></dependency>
- 重启工程,访问
localhost:53070/auth/r/r1
,自动进入/login
页面,/login
页面是由Spring Security提供的
- 那么账号和密码是什么呢?我们需要进行安全配置,创建WebSecurityConfig配置类,继承WebSecurityConfigurerAdapter
- 配置用户信息
@Bean public UserDetailsService userDetailsService() {// 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();// 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());return manager; }
- 密码方式,暂时采用明文的方式
@Bean public PasswordEncoder passwordEncoder() {return NoOpPasswordEncoder.getInstance(); }
- 安全拦截机制,
/r/**
开头的请求需要认证
{% note info no-icon %}protected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/r/**").authenticated().anyRequest().permitAll().and().formLogin().successForwardUrl("/login-success");http.logout().logoutUrl("/logout"); }
- 配置说明:
- 通过
authorizeRequests()
方法来配置请求授权规则。 - 使用
antMatchers()
方法指定需要进行访问控制的URL
路径模式。在这里,/r/**
表示所有以/r/
开头的 URL 都需要进行授权访问。 - 使用
authenticated()
方法指定需要进行身份验证的请求。 - 使用
anyRequest()
方法配置除了/r/**
以外的所有请求都不需要进行身份验证。 - 使用
permitAll()
方法表示任何用户都可以访问不需要进行身份验证的URL
。 - 使用
formLogin()
方法配置登录页表单认证,其中successForwardUrl()
方法指定登录成功后的跳转页面。 - 使用
logout()
方法配置退出登录,其中logoutUrl()
方法指定退出登录的URL
。
{% endnote %}
- 通过
- 完整代码如下
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Beanpublic UserDetailsService userDetailsService() {// 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();// 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());return manager;}@Beanpublic PasswordEncoder passwordEncoder() {return NoOpPasswordEncoder.getInstance();}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/r/**").authenticated().anyRequest().permitAll().and().formLogin().successForwardUrl("/login-success");http.logout().logoutUrl("/logout");}
}
- 重启工程
- 访问
localhost:53070/auth/user/52
可以正常访问 - 访问
localhost:53070/auth/r/r1
会被拦截,显示登录页面
- 访问
授权测试
- 用户认证通过去访问系统资源时,
Spring Security
进行授权控制,判断用户是否有该资源的访问权限- 如果有则继续访问
- 如果没有则拒绝访问
- 下面测试授权功能
- 配置用户拥有哪些权限,在WebSecurityConfig中我们已经配置过了
@Bean public UserDetailsService userDetailsService() {// 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();// 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());return manager; }
- 指定资源与权限的关系
{% note info no-icon %}
- 什么是系统的资源?
{% endnote %}
{% note pink no-icon %} - 比如:查询一个用户的信息,用户信息就是系统的资源,要访问资源需要通过
URL
,所以我们在Controller
中定义的每个HTTP
的接口就是访问资源的接口。
{% endnote %} - 下面我们在Controller中配置
/r/r1
需要p1权限,/r/r2
需要p2权限
@RequestMapping("/r/r1") + @PreAuthorize("hasAnyAuthority('p1')")public String r1() {return "访问r1资源";}@RequestMapping("/r/r2") + @PreAuthorize("hasAuthority('p2')")public String r2() {return "访问r2资源";}
- 现在重启工程
Kyle
只有p1
权限,所以无法访问/r/r2
,访问会报403
错误Lucy
只有p2
权限,所以无法访问/r/r1
,访问会报403
错误
{% note warning no-icon %}
- 注意:如果访问上不加
@PreAuthorize
注解,此方法没有授权控制
{% endnote %}
- 整个授权的过程如图所示
工作原理
- 通过测试认证和授权两个功能,我们了解了
Spring Security
的基本使用方法,下面我们来了解一下它的工作流程 Spring Security
所解决的问题是安全访问控制
,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问到它所期望的资源。- 根据我们之前学过的知识,可以通过Filter或AOP等技术来实现,
Spring Securit
y对Web资源的保护是靠Filter
实现的,所以我们从Filter
来入手,逐步深入Spring Security
原理 - 当初始化
Spring Security
时,会创建一个名为SpringSecurityFilterChain的Servlet
过滤器,类型为org.springframework.security.web.FilterChainProxy
,它实现了javax.servlet.Filter
,因此外部的请求会经过此类,下图是Spring Security
过虑器链结构图:
FilterChainProxy
是一个代理,真正起作用的是FilterChainProxy
中的SecurityFilterChain
所包含的各个Filter
,同时这些Filter作为Bean
被Spring
管理,它们是Spring Security
的核心,各有各的职责,同时它们并不直接处理用户的认证
,也不直接处理用户的授权,而是将它们交给了认证管理器(AuthenticationManager)
和决策管理器(AccessDecisionManager)
进行处理Spring Security
功能的实现主要是由一系列过滤器链相互配合完成的
- 下面介绍过滤器链中主要的几个过滤器及其作用
SecurityContextPresistenceFilter
:这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的SecurityContextRepository中获取SecurityContext,然后把它设置给SecurityContextHolder,在请求完成后,将SecurityContextRepository持有的SecurityContext再保存到配置好的SecurityContextRepository,同时清楚SecurityContextHolder所持有的SecurityContextUsernamePasswordAuthenticationFilter
:用于处理来自表单提交的认证,该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的AuthenticationSuccessHandler和AuthenticationFailureHandler,这些都可以根据需求做相关改变FilterSecurityInterceptor
是用于保护web资源的,使用AccessDecisionManager
对当前用户进行授权访问ExeptionTranslationFilter
能够捕获来自FilterChain
所有的异常,并进行处理。但是他只会处理两类异常:AuthenticationException
和AccessDeniedException
,其他的异常它会继续抛出
- SpringSecurity的执行流程如下
- 用户提交用户名、密码被
SecurityFilterChain
中的UsernamePasswordAuthenticationFilter
过滤器获取到,封装为请求Authentication
,通常情况下是UsernamePasswordAuthenticationToken
这个实现类 - 然后过滤器将
Authentication
提交至认证管理器(AuthenticationManager)
进行认证 - 认证成功后,
AuthenticationManager
身份管理器返回一个被填充满了信息(权限信息、身份信息、细节信息等,但密码通常会被移除)的Authentication
实例 SecurityContextHolder
将第三步填充了信息的Authentication
通过SecurityContextHolder.getContext().setAuthentication()
方法,设置到其中。AuthenticationManager
接口是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager
,而Spring Security
支持多种认证方式,因此ProviderManager
维护着一个List<AuthenticationProvider>
列表,存放多种认证方式,最终实际的认证工作是AuthenticationProvider
完成的。Web表单对应的AuthenticationProvider
的实现类为DaoAuthenticationProvider
,它的内部又维护着一个UserDetailsService
负责UserDetails
的获取。最终AuthenticationProvider将UserDetails
填充至Authentication
- 用户提交用户名、密码被
什么是OAuth2
OAuth2认证流程
- 前面我们提到的微信扫码认证,是一种第三方认证方式,这种认证方式是基于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认证的例子,微信认证扫码登录的过程:
- 具体流程如下
- 用户点击微信扫码登录,微信扫码的目的是通过微信认证登录目标网站,目标网站需要从微信获取当前用户的身份信息才会让当前用户在目标网站登录成功
- 首先搞清楚几个概念
资源
:用户信息,在微信中存储资源拥有者
:用户是用户信息资源的拥有者认证服务
:微信负责认证当前用户的身份,负责为客户端颁发令牌客户端
:客户端会携带令牌请求微信获取用户信息
- 首先搞清楚几个概念
- 用户授权网站访问用户信息
- 资源拥有者扫描二维码,表示资源拥有者请求微信进行认证,微信认证通过向用户手机返回授权页面(让你确认登录)
- 询问用户是否授权目标网站访问自己在微信的用户信息,用户点击(确认登录)表示同意授权,微信认证服务器会颁发一个授权码给目标网站
- 只有资源拥有者同意,微信才允许目标网站访问资源
- 目标网站获取到授权码
- 携带授权码请求微信认证服务器,申请令牌(此交互过程用户看不到)
- 微信认证服务器想目标网站响应令牌(此交互过程用户看不到)
- 目标网站携带令牌请求微信服务器获取用户的基本信息
- 资源服务器返回受保护资源,即用户信息
- 目标网站接收到用户信息,此时用户在目标网站登录成功
- 用户点击微信扫码登录,微信扫码的目的是通过微信认证登录目标网站,目标网站需要从微信获取当前用户的身份信息才会让当前用户在目标网站登录成功
OAUTH 2.0
认证流程如下
OAUTH 2.0
包括以下角色客户端
:本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,例如:手机客户端、浏览器等资源拥有者
:通常为用户,也可以是应用程序,即该资源的拥有者授权服务器(认证服务器)
:认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌资源服务器
:存储资源的服务器
- 上图中
A
表示:客户端请求资源拥有者授权B
表示:资源拥有者授权客户端,即用户授权目标网站访问自己的用户信息C
表示:目标网站携带授权码请求认证D
表示:认证通过,颁发令牌E
表示:目标网站携带令牌请求资源服务器,获取资源F
表示:资源服务器校验令牌通过后,提供受保护的资源
OAuth2在本项目的应用
- OAuth2是一个标准的开放的授权协议,应用程序可以根据自己的需求去使用
- 本项目使用OAuth2实现如下目标
- 学成在线访问第三方系统的资源
- 本项目要接入微信扫码登录,所以本项目要是用OAuth2协议访问微信中的用户信息
- 外部系统访问学成在线的资源
- 同样当第三方系统想要访问学成在线网站的资源,也可以基于OAuth2协议来访问用户信息
- 学成在线前端(客户端)访问学成在线微服务的资源
- 本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议
- 学成在线访问第三方系统的资源
OAuth2的授权模式
- Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式。前面举的微信扫码登录的例子就是基于授权码模式。
- 这四种模式中,授权码模式和密码模式应用较多,这里使用Spring Security演示授权码模式、密码模式。
授权码模式
- OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,然后通过令牌去获取资源
- 授权码模式简单理解就是使用授权码去获取令牌,要想获取令牌,首先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取
- 下图是授权码模式的交互图
- 用户打开浏览器
- 通过浏览器访问客户端
- 通过浏览器想认证服务请求授权(用户扫描二维码)
- 请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址
- 认证服务向资源拥有者返回授权页面
- 资源拥有者亲自授权同意(用户点击
同意登录
) - 通过浏览器向认证服务发送授权同意
- 认证服务向客户端地址重定向,并携带授权码
- 客户端收到授权码
- 客户端携带授权码向认证服务申请令牌
- 认证服务向客户端颁发令牌
授权码模式测试
-
要想测试授权码模式,首先要配置授权服务,即上图中的认证服务器,需要配置授权服务及令牌策略
-
拷贝黑马提供的AuthorizationServer.java、TokenConfig.java到config包下
Authorization
使用@EnableAuthorizationServer
注解标识并继承AuthorizationServerConfigurerAdapter
来配置OAuth2.0
授权服务器
@Configuration @EnableAuthorizationServer public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {··· }
AuthorizationServerConfigurerAdapter
要求配置以下几个类- AuthorizationServerSecurityConfigurer:用来配置令牌断点的安全约束
- ClientDetailsServiceConfigurer:用来配置客户端详情服务
- 随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详情信息
- AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token services)
public class AuthorizationServerConfigurerAdapter implements AuthorizationServerConfigurer {public AuthorizationServerConfigurerAdapter() {}public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {}public void configure(ClientDetailsServiceConfigurer clients) throws Exception {}public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {} }
- TokenConfig为令牌策略配置类
- 暂时使用InMemoryTokenStore在内存存储令牌,令牌的有效期等信息配置如下
@Configuration public class TokenConfig {@AutowiredTokenStore tokenStore;@Beanpublic TokenStore tokenStore() {//使用内存存储令牌(普通令牌)return new InMemoryTokenStore();}@Bean(name = "authorizationServerTokenServicesCustom")public AuthorizationServerTokenServices tokenService() {DefaultTokenServices service = new DefaultTokenServices();service.setSupportRefreshToken(true);//支持刷新令牌service.setTokenStore(tokenStore);//令牌存储策略service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天return service;} }
- 配置认证管理Bean
@EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}···}
-
重启认证服务
- get请求获取授权码,地址:http://{{auth_host}}/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://localhost/
- 参数列表如下
- client_id:客户端准入标志
- response_type:授权码模式固定为code
- scope:客户端权限
- redirect_uri:跳转uri,当授权码申请成功后门会跳转到此地址,并在后面带上code参数(授权码)
- 输入账号Kyle、密码123登录成功,输入http://{{auth_host}}/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://localhost/ 显示授权页面
- 授权
XcWebApp
访问自己受保护的资源,选择同意
- 参数列表如下
- 请求成,重定向至http://localhost/?code=授权码,例如:http://localhost/?code=H7J61Z
- 使用HttpClient工具POST申请令牌:
- 参数列表如下
- client_id:客户端准入标识
- client_secret:客户端秘钥
- grant_type:授权类型,填写authorization_code,表示授权码模式
- code:授权码,就是刚刚获取的授权码。
{% note warning no-icon %}- 注意:授权码只使用一次就无效了,需要重新申请
{% endnote %}
- 注意:授权码只使用一次就无效了,需要重新申请
- redirect_uri:申请授权码时的跳转uri,一定要和申请授权码时使用的redirect_uri一致
- HttpClient脚本如下
### 授权码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=W34ttV&redirect_uri=http://localhost/
- 申请令牌成功如下所示
{ "access_token": "c75d121f-7430-4cf9-9ff6-eb25e5c01ca0", "token_type": "bearer", "refresh_token": "b950149e-40e8-47f3-9f1d-1c6df74dd69f", "expires_in": 7199, "scope": "all" }
- 说明
access_token
:访问令牌,用于访问资源使用token_type
:bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时,需要在head中加入bearer空格令牌内容refresh_token
:当令牌快过期时使用刷新令牌,可以再次生成令牌expires_in
:过期时间scope
:令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权
- 参数列表如下
- get请求获取授权码,地址:http://{{auth_host}}/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://localhost/
密码模式
- 密码模式相较于授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如图
- 资源提供者提供账号和密码
- 客户端向认证服务申请令牌,请求中携带账号和密码
- 认证服务校验账号和密码正确,颁发令牌
- 开始测试
- POST请求获取令牌
{{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
- 参数列表如下
- client_id:客户端准入标识
- client_secret:客户端秘钥
- grant_type:授权类型,填写password标识密码模式
- username:资源拥有者用户名
- password:资源拥有者密码
- 授权服务器将令牌发送给client,使用HttpClient进行测试
### 密码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
- 返回示例
{ "access_token": "c75d121f-7430-4cf9-9ff6-eb25e5c01ca0", "token_type": "bearer", "refresh_token": "b950149e-40e8-47f3-9f1d-1c6df74dd69f", "expires_in": 4687, "scope": "all" }
- 这种模式十分简单,但是却意味着直接将用户敏感信息泄露给了client,因此说明这种模式只能用于client是我们自己开发的情况下
- POST请求获取令牌
本项目的应用方式
- 通过演示授权码模式和密码模式,授权码模式适合客户端和认证服务非同一个系统的情况,所以本项目采用授权码模式完成微信扫码认证,采用密码模式作为前端请求微服务的认证方式
JWT
普通令牌问题
- 客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器会校验令牌的合法性。
- 资源服务器如何校验令牌的合法性?这里以OAuth2的密码模式为例进行说明
- 前三步获取令牌我们已经在代码中完成了,这里从第四步开始说明
- 客户端鞋带令牌访问资源服务,获取资源
- 资源服务远程请求认证服务校验令牌的合法性
- 如果令牌核发,资源服务想客户端返回资源
- 这里存在一个问题:校验令牌需要远程请求认证服务,客户端每次访问都会远程校验,执行性能低
- 如果能够让资源服务自己校验令牌的合法性,就可以省去远程请求认证服务的成本,提高了性能,如下图
- 如何解决上面的问题,实现资源服务自行校验令牌呢?
- 令牌采用JWT格式即可解决上面的问题,用户认证后会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权
什么是JWT
- 什么是JWT?
- 官网:https://jwt.io/
- Json Web Token(JWT)是一种使用Json格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递Json对象,传递的对象经过数字签名可以被验证和信任,它可以是应用HMAC算法或使用RSA的公钥/私钥来签名,防止内容篡改
- 使用JWT可以实现无状态认证。什么是
无状态认证
? - 传统的基于Session的方式是
有状态认证
,用户登录成功,将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用 - 如图,当用户访问应用服务,每个应用服务都会去服务器查看Session信息,如果没有Session,则认证用户没有登录,此时会重新认证,而解决这个问题的颁发是Session复制黏贴
- 如果是基于令牌技术,在分布式系统中实现认证,服务端不用存储Session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从JWT解析出用户信息,这个过程就是无状态认证
- JWT令牌的优点
- JWT基于Json,非常方便解析
- 可以在令牌中自定义丰富的内容,易扩展
- 通过非对称加密算法及数字签名技术,JWT防篡改,安全性高
- 资源服务使用JWT可不依赖认证服务即可完成授权
- 缺点
- JWT令牌较长,占存储空间比较大,下面是一个JWT令牌的示例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQyNTQ2NzIsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijg4OTEyYjJkLTVkMDUtNGMxNC1iYmMzLWZkZTk5NzdmZWJjNiIsImNsaWVudF9pZCI6ImMxIn0.wkDBL7roLrvdBG2oGnXeoXq-zZRgE9IVV2nxd-ez_oA
- JWT令牌由三部分组成,每部分中间使用点(.)分隔,例如xxxx.yyyyyy.zzzzzzz
- Header:第一部分是头部
- 头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC、SHA256或RSA),一个例子如下
{"alg": "HS256","typ": "JWT" }
- 将上面的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分
- Payload:第二部分是负载,内容也是一个Json对象
- 它是存放有效信息的地方,它可以存放JWT提供的现成字段,如iss(签发者)、exp(过期时间戳)、sub(面向的用户)等,也可以自定义字段
- 此部分不建议存放敏感信息,因为此部分可以解码还原原始内容
- 最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分
{"sub": "1234567890","name": "456","admin": true }
- Sugbature:第三部分是签名,此部分用于防止JWT内容被篡改。
- 这个部分使用Base64Url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用Header中声明的签名算法进行签名
HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
- base64UrlEncode(header):JWT令牌的第一部分
- base64UrlEncode(payload):JWT令牌的第二部分
{% note info no-icon %}
- Header:第一部分是头部
- JWT令牌由三部分组成,每部分中间使用点(.)分隔,例如xxxx.yyyyyy.zzzzzzz
- JWT令牌较长,占存储空间比较大,下面是一个JWT令牌的示例
- 为什么JWT可以防止篡改?
{% endnote %}
{% note pink no-icon %}
-
第三部分使用签名算法对第一部分和第二部分的内容进行签名,常见的签名算法是HS526,常见的还有MD5、SHA等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容,那么服务器验证前面就会失败,要想保证签名正确,必须保证内容、密钥与签名前一致
{% endnote %}
-
从上图中可以看出,认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造JWT令牌
-
JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密、解密,非对称加密效率低,但相比较于对称加密更加安全
测试生成JWT令牌
- 在认证服务中配置JWT令牌服务,即可实现生成JWT格式的令牌
@Configuration
public class TokenConfig {private String SIGNING_KEY = "mq123";@AutowiredTokenStore tokenStore;@Autowiredprivate JwtAccessTokenConverter accessTokenConverter;@Beanpublic TokenStore tokenStore() {return new JwtTokenStore(accessTokenConverter());}@Beanpublic JwtAccessTokenConverter accessTokenConverter() {JwtAccessTokenConverter converter = new JwtAccessTokenConverter();converter.setSigningKey(SIGNING_KEY);return converter;}//令牌管理服务@Bean(name = "authorizationServerTokenServicesCustom")public AuthorizationServerTokenServices tokenService() {DefaultTokenServices service = new DefaultTokenServices();service.setSupportRefreshToken(true);//支持刷新令牌service.setTokenStore(tokenStore);//令牌存储策略TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));service.setTokenEnhancer(tokenEnhancerChain);service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天return service;}
}
- 重启认证服务,通过HttpClient通过密码模式申请令牌
### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
- 生成的JWT示例如下
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQyNzQ5NiwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiY2IyOTI0ZjYtOGZiOS00N2ViLThjNGEtMWFmMjkzZWU4NTg4IiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.aVZOsHBEuowof41HgV2auyDrRh9ZiNfwn4qoQWjla7o", "token_type": "bearer", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImNiMjkyNGY2LThmYjktNDdlYi04YzRhLTFhZjI5M2VlODU4OCIsImV4cCI6MTY3ODY3OTQ5NiwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiNjFhNWRmOGItZTc3ZS00YmVkLWE3OTQtZTlmMjJkM2FmMTYyIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.JqEL9V4Yn8tWYtvH46wtbAgJQ1dEoseuWyQhDdZNveo", "expires_in": 7199, "scope": "all", "jti": "cb2924f6-8fb9-47eb-8c4a-1af293ee8588" }
access_token
:生成的JWT令牌,用于访问资源使用token_type
:bearer是在RFC6750中定义的一种token类型,在携带JWT访问资源时,需要在head中加入bearer jwt令牌内容refresh_token
:当JWT令牌快过期时使用刷新令牌可以再次生成JWT令牌expires_in
:过期时间(秒)scope
:令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权jti
:令牌的唯一表示
- 我们可以通过check_token接口校验jwt令牌
### 校验JWT令牌
POST {{auth_host}}/auth/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQyOTg5MywiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiMzNhMzg4YWMtNzNmYS00ODBmLWEzMWUtOTdmOTJmMjBkNWZkIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.cTcfIzL2avSp2XEsPvGU2IoJ060ooln1hARZCrvCxp4
- 响应示例如下
{"aud": ["xuecheng-plus"],"user_name": "Kyle","scope": ["all"],"active": true,"exp": 1678429893,"authorities": ["p1"],"jti": "33a388ac-73fa-480f-a31e-97f92f20d5fd","client_id": "XcWebApp"
}
测试资源服务校验令牌
- 拿到了JWT令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务,例如:内容管理服务,当客户端申请到JWT令牌,携带JWT去内容管理服务查询课程信息,此时内容管理服务需要对JWT进行校验,只有JWT合法才可以继续访问,如下图
- 在内容管理服务的content-api中添加依赖
<!--认证相关-->
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
- 在内容管理服务的
content-api
中添加TokenConfig
配置类
@Configuration
public class TokenConfig {private String SIGNING_KEY = "mq123";@Beanpublic JwtAccessTokenConverter accessTokenConverter(){JwtAccessTokenConverter converter = new JwtAccessTokenConverter();converter.setSigningKey(SIGNING_KEY);return converter;}@Beanpublic TokenStore tokenStore() {return new JwtTokenStore(accessTokenConverter());}
}
- 添加资源服务配置类
ResourceServerConfig
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {public static final String RESOURCE_ID = "xuecheng-plus";@AutowiredTokenStore tokenStore;@Overridepublic void configure(ResourceServerSecurityConfigurer resources) {resources.resourceId(RESOURCE_ID).tokenStore(tokenStore).stateless(true);}@Overridepublic void configure(HttpSecurity http) throws Exception {http.csrf().disable() // 禁用 CSRF 保护.authorizeRequests() //配置对请求的授权策略.antMatchers("/r/**", "/course/**").authenticated() // 指定 "/r/" 和 "/course/" 这两个路径需要进行身份认证才能访问。.anyRequest().permitAll(); // 允许所有其他请求(除了上面指定的路径之外)都可以被访问,不需要进行身份认证。}
}
- 重启内容管理服务,使用HttpClient进行测试
- 访问根据课程id查询课程接口
### 根据课程id查询课程基本信息 GET {{content_host}}/content/course/22 Content-Type: application/json
- 返回
{ "error": "unauthorized", "error_description": "Full authentication is required to access this resource" }
- 从返回信息可知,当前没有认证
- 携带JWT令牌访问接口
- 首先申请令牌
### 密码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
- 携带JWT令牌访问资源服务地址
GET {{content_host}}/content/course/160 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQzOTMwOSwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiNTAxNDNiZTItOGM3ZC00MmUzLWEwNDMtMTQwMGQ5NWQ5MmZiIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.o3nWLeRkJncEnnZ0egFmBpyC8Keq-L8IY6k0Uc0a96c
- 携带JWT令牌,且JWT令牌正确,则正常访问资源服务的内容
{ "id": 160, "companyId": 1232141425, "companyName": null, "name": "猫片", "users": "不知道啊不知道啊不知道啊a a a ", "tags": "", "mt": "1-5", "st": "1-5-4", "grade": "204003", "teachmode": "200002", "description": null, "pic": "/mediafiles/2023/03/03/76ac562669dc346992af9dd039060e7b.jpg", "createDate": "2023-03-02 17:17:07", "changeDate": "2023-03-05 11:09:31", "createPeople": null, "changePeople": null, "auditStatus": "203002", "status": "203001", "charge": "201000", "price": 0.0, "originalPrice": null, "qq": "", "wechat": "", "phone": "", "validDays": 365, "mtName": "人工智能", "stName": "计算机视觉" }
- 如果JWT令牌错误,咋会报令牌无效
{ "error": "invalid_token", "error_description": "Cannot convert access token to JSON" }
- 访问根据课程id查询课程接口
测试获取用户身份
- JWT令牌中记录了用户身份信息,当客户端携带JWT访问资源服务,资源服务验签通过后,将两部分内容还原,即可取出用户的身份信息,并将用户身份信息放在了
SecurityContextHolder
上下文,SecurityContext
与当前线程进行绑定,方便获取用户身份 - 继续以查询课程接口为例,进入查询课程接口的代码中,添加获取用户身份的代码
@ApiOperation("根据课程id查询课程基础信息")@GetMapping("/course/{courseId}")public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
+ Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
+ System.out.println("当前用户身份为:" + principal);return courseBaseInfoService.getCourseBaseInfo(courseId);}
- 重启内容管理服务,使用HttpClient测试接口,查看控制台是否会输出用户身份
当前用户身份为:Kyle
网关鉴权
什么是网关鉴权
- 到目前为止,测试通过了认证服务颁发的JWT令牌,客户端携带JWT访问资源服务,资源服务会对JWT的合法性进行验证,如下图
- 仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示
- 所有访问微服务的请求都要经过网关,在网关进行用户身份的认证,可以将很多非法的请求拦截到微服务以外,这叫做网关鉴权
- 下面需要明确网关鉴权的职责
- 网站白名单维护:针对不用认证的URL全部放行
- 校验JWT的合法性:除了白名单剩下的就是需要认证的请求,网关需要验证JWT的合法性,JWT合法则说明用户身份合法,否则说明身份不合法,拒绝继续访问
{% note info no-icon %}
- 网关负责授权吗?
{% endnote %}
{% note pink no-icon %}
- 网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口
{% endnote %}
实现网关就鉴权
- 下面市县网关鉴权
- 在网关工程添加依赖
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-security</artifactId> </dependency> <dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-oauth2</artifactId> </dependency> <dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId> </dependency> <dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId> </dependency>
- 拷贝黑马提供的网关鉴权配置类到gateway-api的config包下
{% tabs gateway网关鉴权 %}
@Component @Slf4j public class GatewayAuthFilter implements GlobalFilter, Ordered {//白名单private static List<String> whitelist = null;static {//加载白名单try (InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties");) {Properties properties = new Properties();properties.load(resourceAsStream);Set<String> strings = properties.stringPropertyNames();whitelist = new ArrayList<>(strings);} catch (Exception e) {log.error("加载/security-whitelist.properties出错:{}", e.getMessage());e.printStackTrace();}}@Autowiredprivate TokenStore tokenStore;@Overridepublic Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {String requestUrl = exchange.getRequest().getPath().value();AntPathMatcher pathMatcher = new AntPathMatcher();//白名单放行for (String url : whitelist) {if (pathMatcher.match(url, requestUrl)) {return chain.filter(exchange);}}//检查token是否存在String token = getToken(exchange);if (StringUtils.isBlank(token)) {return buildReturnMono("没有认证", exchange);}//判断是否是有效的tokenOAuth2AccessToken oAuth2AccessToken;try {oAuth2AccessToken = tokenStore.readAccessToken(token);boolean expired = oAuth2AccessToken.isExpired();if (expired) {return buildReturnMono("认证令牌已过期", exchange);}return chain.filter(exchange);} catch (InvalidTokenException e) {log.info("认证令牌无效: {}", token);return buildReturnMono("认证令牌无效", exchange);}}/*** 获取token*/private String getToken(ServerWebExchange exchange) {String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization");if (StringUtils.isBlank(tokenStr)) {return null;}String token = tokenStr.split(" ")[1];if (StringUtils.isBlank(token)) {return null;}return token;}private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) {ServerHttpResponse response = exchange.getResponse();String jsonString = JSON.toJSONString(new RestErrorResponse(error));byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8);DataBuffer buffer = response.bufferFactory().wrap(bits);response.setStatusCode(HttpStatus.UNAUTHORIZED);response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");return response.writeWith(Mono.just(buffer));}@Overridepublic int getOrder() {return 0;} }
public class RestErrorResponse implements Serializable {private String errMessage;public RestErrorResponse(String errMessage){this.errMessage= errMessage;}public String getErrMessage() {return errMessage;}public void setErrMessage(String errMessage) {this.errMessage = errMessage;} }
@EnableWebFluxSecurity @Configuration public class SecurityConfig {//安全拦截配置@Beanpublic SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {return http.authorizeExchange().pathMatchers("/**").permitAll().anyExchange().authenticated().and().csrf().disable().build();} }
{% endtabs %}@Configuration public class TokenConfig {String SIGNING_KEY = "mq123";@Beanpublic TokenStore tokenStore() {return new JwtTokenStore(accessTokenConverter());}@Beanpublic JwtAccessTokenConverter accessTokenConverter() {JwtAccessTokenConverter converter = new JwtAccessTokenConverter();converter.setSigningKey(SIGNING_KEY);return converter;} }
3. 配置白名单文件security-whitelist.properties
/auth/**=认证地址 /content/open/**=内容管理公开放文件接口 /media/open/**=媒资管理公开访问接口
- 重启网关工程,进行测试
- 申请令牌
### 密码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
- 通过网关访问资源服务(将端口换为网关端口)
GET {{gateway_host}}/content/course/40 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQ0MTU3MiwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiZWJkNDkzNjgtMjc4My00OTAxLWE5MTMtZGM5ZjUyYTg5ZWQ2IiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.6V9OaU5FutGp9Ol2QzaP57HVxe9w1d5S0Y5TdWLDxzw
- 当token正确时可以正常访问资源服务,token验证失败时,会返回token失效
{ "errMessage": "认证令牌无效" }
{% note warning no-icon %}
- 注意:网关鉴权功能调试通过后,由于目前还没有开发认证功能,前端请求网关的URL不在白名单时,会出现
没有认证
错误,所以暂时在白名单中添加全部放行配置,待认证功能开发完成后,再屏蔽全部放行配置
/**=暂时全部放开
/auth/**=认证地址
/content/open/**=内容管理公开访问接口
/media/open/**=媒资管理公开访问接口
{% endnote %}
用户认证
需求分析
- 至此我们了解了使用Spring Security进行认证授权的过程,本届实现用户认证功能。目前各大网站的认证方式也是十分丰富:账号密码认证、手机验证码认证、扫码认证等,所以本项目也要支持多种认证方式
连接用户中心数据库
连接数据库认证
- 到目前为止,我们的用户认证流程如下
- 认证所需要的用户信息存储在xc_user库中,之前我们是将用户信息硬编码,放在内存中的,现在我们要从数据库来查询用户信息来登录
- 如何使用Spring Security连接数据库认证?
- 用户提交账号和密码由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息
- UserDetailsService是一个接口
public interface UserDetailsService {UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}
- UserDetails使用户信息接口
public interface UserDetails extends Serializable {Collection<? extends GrantedAuthority> getAuthorities();String getPassword();String getUsername();boolean isAccountNonExpired();boolean isAccountNonLocked();boolean isCredentialsNonExpired();boolean isEnabled();
}
- 我们只要实现UserDetailsService接口查询数据库得到用户信息返回UserDetails类型的用户信息即可
- 首先屏蔽原来定义的UserDetailsService
// @Bean // public UserDetailsService userDetailsService() { // // 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查 // InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); // // 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2 // manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build()); // manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build()); // return manager; // }
- 下面自定义UserDetailsService
@Service public class UserDetailsImpl implements UserDetailsService {@AutowiredXcUserMapper xcUserMapper;/**** @param s 用户输入的登录账号* @return UserDetails* @throws UsernameNotFoundException*/@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {// 没别的意思,只是变量名看着舒服String name = s;// 根据username去XcUser表中查询对应的用户信息XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));// 返回NULL表示用户不存在,SpringSecurity会帮我们处理,框架抛出异常用户不存在if (user == null) {return null;}// 取出数据库存储的密码String password = user.getPassword();//如果查到了用户拿到正确的密码,最终封装成一个UserDetails对象给spring security框架返回,由框架进行密码比对return User.withUsername(user.getUsername()).password(password).authorities("test").build();} }
- 写到这里,我们需要清楚框架调用loadUserByUsername()方法拿到用户信息后是如何执行的
- 数据库中的密码是加密过的,用户输入的时候是明文,我们需要修改密码格式器PasswordEncoder
- 原来使用的是NoOpPasswordEncoder,它是通过明文的方式比较的密码
- 现在我们需要修改为BCryptPasswordEncoder,它是将用户输入的密码编码为BCrypt格式与数据库中的密码进行比对
@Bean public PasswordEncoder passwordEncoder() { - return NoOpPasswordEncoder.getInstance(); + return new BCryptPasswordEncoder(); }
- 我们通过测试代码测试BCryptPasswordEncoder,如下
public static void main(String[] args) {String password = "123456";BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();for (int i = 0; i < 5; i++) {// 每个计算出的Hash值都不一样String encodePsw = encoder.encode(password);// 虽然Hash值不一样,但是校验是可以通过的System.out.println("转换后密码:" + encodePsw + "比对情况:" + encoder.matches(password, encodePsw));} }// 转换后密码:$2a$10$6hbvtCtgcISvbBHJ.UnhPO1io7StF.ySPkmAvzO/efvNmHVVJZOeK比对情况:true // 转换后密码:$2a$10$ufYW9qXSAk0N201B/wCR7uGrzygawnwXtyL2vKpDLAOCOkF33sGnK比对情况:true // 转换后密码:$2a$10$DEaVxYHakIE/kDvAU4eC7OZ7c9kqKBJedClVxDPnYH.zwuZvCRnzm比对情况:true // 转换后密码:$2a$10$s2qgaKGgULYQ7tce2u6TIeHopap4HqfyghJYu1vdDZ2WcNk70ykFe比对情况:true // 转换后密码:$2a$10$XQaQJIfXyd/UvMHC..uBNuDXNVrZHnEGn.tW0oSB6WVjdsZLFpkGq比对情况:true
- 修改数据库中的密码为BCrype格式,并且记录明文密码,稍后申请令牌是需要
- 修改密码编码方式还需要客户端的密钥更改为BCrypt格式
@Overridepublic void configure(ClientDetailsServiceConfigurer clients)throws Exception {clients.inMemory()// 使用in-memory存储.withClient("XcWebApp")// client_id.secret(new BCryptPasswordEncoder().encode("XcWebApp"))//客户端密钥.resourceIds("xuecheng-plus")//资源列表.authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials.scopes("all")// 允许的授权范围.autoApprove(false)//false跳转到授权页面 .redirectUris("http://localhost/");//客户端接收授权码的重定向地址}
- 现在重启认证服务,使用HttpClient进行测试
### 密码模式 POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=111111
- 输入正确的账号密码,申请令牌成功
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQ1MTUxNiwiYXV0aG9yaXRpZXMiOlsidGVzdCJdLCJqdGkiOiJkOWUwYjU0ZS03Zjg4LTQ2NjAtYjFlZS04ZWQzYjYzZmQwNjMiLCJjbGllbnRfaWQiOiJYY1dlYkFwcCJ9.NaS3hmpDtX3zkXvnZWoo9ROEWgYeA6GoxBzy_lOxzvA", "token_type": "bearer", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImQ5ZTBiNTRlLTdmODgtNDY2MC1iMWVlLThlZDNiNjNmZDA2MyIsImV4cCI6MTY3ODcwMzUxNiwiYXV0aG9yaXRpZXMiOlsidGVzdCJdLCJqdGkiOiI5YjE4OTUwOS1iYmEzLTRkMTctYTNkNC05OWQwZGI5NjU0MDAiLCJjbGllbnRfaWQiOiJYY1dlYkFwcCJ9.548w5CdQiIU5_k1qRBjzM-PMBqy-XX3zr17tQS6g6CM", "expires_in": 7199, "scope": "all", "jti": "d9e0b54e-7f88-4660-b1ee-8ed3b63fd063" }
- 输入错误的密码,申请令牌失败
{ "error": "invalid_grant", "error_description": "用户名或密码错误" }
扩展用户身份信息
- 用户表中存储了用户的账号、手机号、email、昵称、QQ等信息,UserDetails接口只返回了username、password等信息
public interface UserDetails extends Serializable {Collection<? extends GrantedAuthority> getAuthorities();String getPassword();String getUsername();boolean isAccountNonExpired();boolean isAccountNonLocked();boolean isCredentialsNonExpired();boolean isEnabled();
}
- 我们需要扩展用户身份信息,在JWT令牌中存储用户的昵称、头像、QQ等信息
{% note info no-icon %} - 如何扩展Spring Security的用户身份信息呢?
{% endnote %}
{% note pink no-icon %} - 在认证阶段DaoAuthenticationProvider会调用UserDetailsService查询用户的信息,这里是可以获取到齐全的用户信息。
- 由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路
- 扩展UserDetails,时期包括更多的自定义属性
- 扩展username的内容,例如存入Json数据作为username的内容
- 相较而言,方案2比较简单,而且也不用破坏UserDetails的结构,这里采用方案二
{% endnote %} - 修改UserDetailsImpl如下
@Service
public class UserDetailsImpl implements UserDetailsService {@AutowiredXcUserMapper xcUserMapper;/**** @param s 用户输入的登录账号* @return UserDetails* @throws UsernameNotFoundException*/@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {// 没别的意思,只是变量名看着舒服String name = s;// 根据username去XcUser表中查询对应的用户信息XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));// 返回空表示用户不存在,SpringSecurity会帮我们处理if (user == null) {return null;}// 取出数据库存储的密码String password = user.getPassword();
+ // 用户敏感信息不要设置
+ user.setPassword(null);
+ String userString = JSON.toJSONString(user);// 创建UserDetails对象,并返回,注意这里的authorities必须指定
- return User.withUsername(user.getUsername()).password(password).authorities("test").build();
+ return User.withUsername(userString).password(password).authorities("test").build();}
}
- 重启认证服务,重新生成令牌
### 密码模式
POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=111111
- 校验令牌
### 校验JWT令牌
POST localhost:53070/auth/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ7XCJjb21wYW55SWRcIjpcIjEyMzIxNDE0MjVcIixcImNyZWF0ZVRpbWVcIjpcIjIwMjItMDktMjhUMDg6MzI6MDNcIixcImlkXCI6XCI1MlwiLFwibmFtZVwiOlwiS2lraVwiLFwicGFzc3dvcmRcIjpcIiQyYSQxMCQwcHQ3V2xmVGJuUERUY1d0cC8uMk11NUNUWHZvaG5OUWhSNjI4cXE0Um9LU2MwZEdBZEVnbVwiLFwic2V4XCI6XCIxXCIsXCJzdGF0dXNcIjpcIlwiLFwidXNlcm5hbWVcIjpcIkt5bGVcIixcInV0eXBlXCI6XCIxMDEwMDJcIn0iLCJzY29wZSI6WyJhbGwiXSwiZXhwIjoxNjc4NDUyMzU0LCJhdXRob3JpdGllcyI6WyJ0ZXN0Il0sImp0aSI6Ijc2MDc0MDI4LTBiM2MtNDQ4Mi1hN2Y0LTc1NDI3ZTA2OTFjMSIsImNsaWVudF9pZCI6IlhjV2ViQXBwIn0._GKfGE2s5k0n6VC4_RKQrzdzydWY-WtX3Q_Hc4DxQ1g
- 响应示例如下
{"aud": ["xuecheng-plus"],"user_name": "{\"companyId\":\"1232141425\",\"createTime\":\"2022-09-28T08:32:03\",\"id\":\"52\",\"name\":\"Kiki\",\"password\":\"$2a$10$0pt7WlfTbnPDTcWtp/.2Mu5CTXvohnNQhR628qq4RoKSc0dGAdEgm\",\"sex\":\"1\",\"status\":\"\",\"username\":\"Kyle\",\"utype\":\"101002\"}","scope": ["all"],"active": true,"exp": 1678452354,"authorities": ["test"],"jti": "76074028-0b3c-4482-a7f4-75427e0691c1","client_id": "XcWebApp"
}
- user_name存储了用户信息的JSON格式,在资源服务中就可以取出该JSON格式的内容,转换为用户对象去使用
资源服务获取用户身份
-
下面编写一个工具类在各个微服务中去使用,获取当前登录用户的对象
- 我们可以通过SecurityContextHolder获取user_name,然后将其转换为XcUser对象
@Slf4j public class SecurityUtil {public static XcUser getUser() {try {Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();if (principal instanceof String) {String userJson = principal.toString();XcUser xcUser = JSON.parseObject(userJson, XcUser.class);return xcUser;}} catch (Exception e) {log.error("获取当前登录用户身份信息出错:{}", e.getMessage());e.printStackTrace();}return null;}// 这里使用内部类,是为了不让content工程去依赖auth工程@Datapublic static class XcUser implements Serializable {private static final long serialVersionUID = 1L;private String id;private String username;private String password;private String salt;private String name;private String nickname;private String wxUnionid;private String companyId;/*** 头像*/private String userpic;private String utype;private LocalDateTime birthday;private String sex;private String email;private String cellphone;private String qq;/*** 用户状态*/private String status;private LocalDateTime createTime;private LocalDateTime updateTime;} }
-
下面在内容管理服务中测试此工具类,以查询课程信息接口为例
@ApiOperation("根据课程id查询课程基础信息")@GetMapping("/course/{courseId}")public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
+ SecurityUtil.XcUser user = SecurityUtil.getUser();
- Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
- System.out.println("当前用户身份为:" + principal);
+ System.out.println("当前用户身份为:" + user);return courseBaseInfoService.getCourseBaseInfo(courseId);}
- 下面进行测试
- 重启认证服务、内容管理服务
- 生成新的令牌
- 携带令牌访问内容管理服务的查询课程接口,控制台可以看到输入的用户信息,打断点也行(不过我懒得截图)
当前用户身份为:SecurityUtil.XcUser(id=52, username=Kyle, password=null, salt=null, name=Kiki, nickname=null, wxUnionid=null, companyId=1232141425, userpic=null, utype=101002, birthday=null, sex=1, email=null, cellphone=null, qq=null, status=, createTime=2022-09-28T08:32:03, updateTime=null)
如何支持认证方式多样化
统一认证入口
- 目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等
- 基于当前研究的Spring Security认证流程如何支持多样化的认证方案呢?
- 支持账号和密码认证
- 采用OAuth2协议的密码模式即可实现
- 支持手机号加验证码认证
- 用户认证提交的是手机号和验证码,并不是账号和密码
- 微信扫码认证
- 基于OAuth2协议与微信交互,学成在线网站会向微信服务器申请一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过
- 支持账号和密码认证
- 目前我们测试通过OAuth2的密码模式,用户认证会提交账号和密码,由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息
- 在前面我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息
- 而不同的认证提交方式的数据不一样,例如
- 手机加验证码方式:会提交手机号和验证码
- 账号密码方式:会提交账号、密码、验证码
- 我们可以在loadUserByUsername()方法上做文章,将用户原来提交的账号数据改为提交一个JSON数据,JSON数据可以扩展不同的认证方式所提交的各种参数
- 首先创建一个DTO类用于接收各种认证参数
@Data
public class AuthParamsDto {private String username; //用户名private String password; //域 用于扩展private String cellphone;//手机号private String checkcode;//验证码private String checkcodekey;//验证码keyprivate String authType; // 认证的类型 password:用户名密码模式类型 sms:短信模式类型private Map<String, Object> payload = new HashMap<>();//附加数据,作为扩展,不同认证类型可拥有不同的附加数据。如认证类型为短信时包含smsKey : sms:3d21042d054548b08477142bbca95cfa; 所有情况下都包含clientId
}
- 同时我们也需要修改loadUserByUsername()方法
@Service
public class UserDetailsImpl implements UserDetailsService {@AutowiredXcUserMapper xcUserMapper;/*** @param s 用户输入的登录账号* @return UserDetails* @throws UsernameNotFoundException*/@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
+ AuthParamsDto authParamsDto = null;
+ try {
+ authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
+ } catch (Exception e) {
+ log.error("认证请求数据格式不对:{}", s);
+ throw new RuntimeException("认证请求数据格式不对");
+ }
- // 没别的意思,只是变量名看着舒服
- String name = s;
+ String name = authParamsDto.getUsername();// 根据username去XcUser表中查询对应的用户信息XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));// 返回空表示用户不存在,SpringSecurity会帮我们处理if (user == null) {return null;}// 取出数据库存储的密码String password = user.getPassword();user.setPassword(null);String userString = JSON.toJSONString(user);// 创建UserDetails对象,并返回,注意这里的authorities必须指定return User.withUsername(userString).password(password).authorities("test").build();}
}
- 刚刚我们重写的loadUserByUsername()方法是由DaoAuthenticationProvider调用的,而DaoAuthenticationProvider中有一个方法是用于校验密码的,但是并不是所有的校验方式都需要密码,所以我们现在需要重写一个DaoAuthenticationProviderCustom
{% tabs 重写DaoAuthenticationProviderCustom %}
- DaoAuthenticationProvider中会校验密码
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {if (authentication.getCredentials() == null) {this.logger.debug("Authentication failed: no credentials provided");throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));} else {String presentedPassword = authentication.getCredentials().toString();if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {this.logger.debug("Authentication failed: password does not match stored value");throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));}}}
@Component
public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {// 由于DaoAuthenticationProvider调用UserDetailsService,所以这里需要注入一个@Autowiredpublic void setUserDetailsService(UserDetailsService userDetailsService){super.setUserDetailsService(userDetailsService);}// 屏蔽密码对比,因为不是所有的认证方式都需要校验密码@Overrideprotected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {// 里面啥也不写就不会校验密码了}
}
{% endtabs %}
- 同时也需要修改WebSecurityConfig类,指定DaoAuthenticationProviderCustom
@AutowiredDaoAuthenticationProviderCustom daoAuthenticationProviderCustom;@Overrideprotected void configure(AuthenticationManagerBuilder auth) {auth.authenticationProvider(daoAuthenticationProviderCustom);}
- 重启认证服务,测试申请令牌,传入账号信息改为JSON数据,打个断点,看看传入的请求参数是否为JSON格式
### 密码模式
POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"Kyle","password":"111111"}
- 经过测试,我们发现loadUserByUsername()方法可以正常接收到认证请求中的JSON数据,并且可以正确查询到用户信息
- 有了这些认证参数,我们可以定义一个Service接口去进行各种方式的认证,然后该Service的各种实现类来实现各种方式的认证
- 定义用户信息,为了可扩展性,我们让其继承XcUser
{% note warning no-icon %} - 这里最好不要直接用XcUser类,理由在之前的文章也说过,万一我们需要扩展一些其他的用户信息,那么我们直接修改XcUser类是不现实的,因为XcUser类对应的是数据库中的表。所以即使我们要使用XcUser类作为返回类型,也最好是让一个其他的类继承XcUser
{% endnote %}
@Data
public class XcUserExt extends XcUser {}
- 定义认证Service接口
/*** 认证Service*/
public interface AuthService {/*** 认证方法* @param authParamsDto 认证参数* @return 用户信息*/XcUserExt execute(AuthParamsDto authParamsDto);
}
- 定义AuthService接口的实现类,即各种认证方式
{% tabs AuthService的实现类 %}
- 一个接口的多种实现,我们依靠beanName来做区分,例如这里的password_authservice,见名知意就知道是密码登录方式
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {return null;}
}
- 这里的wx_authservice,一看就是微信扫码方式
{% psw 别问我为啥微信不用WeChat,我怕前端传过来的就是wx,而我又不想去动前端代码 %}
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService {@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {return null;}
}
{% endtabs %}
- 修改loadUserByUsername()
@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {AuthParamsDto authParamsDto = null;try {authParamsDto = JSON.parseObject(s, AuthParamsDto.class);} catch (Exception e) {log.error("认证请求数据格式不对:{}", s);throw new RuntimeException("认证请求数据格式不对");}
+ // 获取认证类型,beanName就是 认证类型 + 后缀,例如 password + _authservice = password_authservice
+ String authType = authParamsDto.getAuthType();
+ // 根据认证类型,从Spring容器中取出对应的bean
+ AuthService authService = applicationContext.getBean(authType + "_authservice", AuthService.class);
+ XcUserExt user = authService.execute(authParamsDto);
- String name = authParamsDto.getUsername();
- // 根据username去XcUser表中查询对应的用户信息
- XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));// 返回空表示用户不存在,SpringSecurity会帮我们处理if (user == null) {return null;}// 取出数据库存储的密码String password = user.getPassword();user.setPassword(null);String userString = JSON.toJSONString(user);// 创建UserDetails对象,并返回,注意这里的authorities必须指定return User.withUsername(userString).password(password).authorities("test").build();}
实现账号密码认证
- 上面我们只是简单定义了账号密码认证的实现类,并没有编写具体逻辑,那这个小节我们就来具体实现账号密码认证
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {@AutowiredXcUserMapper xcUserMapper;@AutowiredPasswordEncoder passwordEncoder;@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {// 1. 获取账号String username = authParamsDto.getUsername();// 2. 根据账号去数据库中查询是否存在XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));// 3. 不存在抛异常if (xcUser == null) {throw new RuntimeException("账号不存在");}// 4. 校验密码// 4.1 获取用户输入的密码String passwordForm = authParamsDto.getPassword();// 4.2 获取数据库中存储的密码String passwordDb = xcUser.getPassword();// 4.3 比较密码boolean matches = passwordEncoder.matches(passwordForm, passwordDb);// 4.4 不匹配,抛异常if (!matches) {throw new RuntimeException("账号或密码错误");}// 4.5 匹配,封装返回XcUserExt xcUserExt = new XcUserExt();BeanUtils.copyProperties(xcUser, xcUserExt);return xcUserExt;}
}
- 修改loadUserByUsername()方法,我们可以将最后的封装UserDetails的相关代码抽取为一个方法
@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {AuthParamsDto authParamsDto = null;try {authParamsDto = JSON.parseObject(s, AuthParamsDto.class);} catch (Exception e) {log.error("认证请求数据格式不对:{}", s);throw new RuntimeException("认证请求数据格式不对");}// 获取认证类型,beanName就是 认证类型 + 后缀,例如 password + _authservice = password_authserviceString authType = authParamsDto.getAuthType();// 根据认证类型,从Spring容器中取出对应的beanAuthService authService = applicationContext.getBean(authType + "_authservice", AuthService.class);XcUserExt user = authService.execute(authParamsDto);
- // 返回空表示用户不存在,SpringSecurity会帮我们处理
- if (user == null) {
- return null;
- }
- // 取出数据库存储的密码
- String password = user.getPassword();
- user.setPassword(null);
- String userString = JSON.toJSONString(user);
- // 创建UserDetails对象,并返回,注意这里的authorities必须指定
- return User.withUsername(userString).password(password).authorities("test").build();
+ return getUserPrincipal(user);}+ public UserDetails getUserPrincipal(XcUserExt user) {
+ String[] authorities = {"test"};
+ String password = user.getPassword();
+ user.setPassword(null);
+ String userJsonStr = JSON.toJSONString(user);
+ UserDetails userDetails = User.withUsername(userJsonStr).password(password).authorities(authorities).build();
+ return userDetails;
+ }
- 重启认证服务,测试申请令牌接口
- 申请令牌,注意JSON数据中要带上authType
### 密码模式 POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"Kyle","password":"111111","authType":"password"}
- 测试密码错误的情况
- 测试账号不存在的情况
- 可以成功获取authType,并正确查询到用户信息
验证码服务
创建验证码服务工程
- 在认证时,一般都需要输入验证码,验证码有什么用?
- 验证码可以防止恶性攻击,例如
- XSS跨站脚本攻击
- CSRF跨站请求伪造攻击
- 验证码可以防止恶性攻击,例如
- 一些比较复杂的图形验证码可以有效防止恶性攻击
- 为了保护系统的安全,在进行一些比较重要的操作时,都需要验证码,例如
- 认证
- 找回密码
- 人机判断
- 支付验证等
- 验证码的类型也有很多:图片、语音、手机短信验证码等
- 本项目创建单独的验证码服务微各业务提供验证码的生成、校验等服务
- 拷贝黑马提供的xuecheng-plus-checkcode验证码服务到自己的工程目录,修改bootstrap.yml,在nacos中新增checkcode-dev.yaml
server:servlet:context-path: /checkcodeport: 53075
- 新增网关路由配置
- id: auth-serviceuri: lb://auth-servicepredicates:- Path=/auth/**
- id: checkcodeuri: lb://checkcodepredicates:- Path=/checkcode/**
- 由于黑马更新了视频,现在验证码是缓存在redis中的,所以我们需要部署redis
docker pull redis
docker run -d --name myredis -p 6379:6379 redis
docker start myredis
- 同时在nacos中配置redis-dev.yaml,group设置为xuecheng-plus-common
spring:redis:host: 192.168.101.128port: 6379password: database: 0lettuce:pool:max-active: 20max-idle: 10min-idle: 0timeout: 10000
- 在验证码模块中引入redis的配置
+ - data-id: redis-${spring.profiles.active}.yaml
+ group: xuecheng-plus-common
+ refresh: true
- 在验证码模块中引入redis依赖
<!--redis依赖-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--common-pool-->
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId>
</dependency>
- 先草草看一遍黑马提供的验证码服务,有个CheckCodeService是验证码接口,其内部还有一个
CheckCodeStore
接口,CheckCodeStore
接口是负责存储验证码的
public interface CheckCodeStore {/*** @param key key* @param value value* @param expire 过期时间,单位秒* @return void* @description 向缓存设置key* @author Mr.M* @date 2022/9/29 17:15*/void set(String key, String value, Integer expire);String get(String key);void remove(String key);}
- 顺藤摸瓜,找到它的实现类为
MemoryCheckCodeStore
,现在我们只需要修改这个类,改为用Redis缓存验证码即可
@Component("MemoryCheckCodeStore")
public class MemoryCheckCodeStore implements CheckCodeService.CheckCodeStore {// 注入StringRedisTemplate@AutowiredStringRedisTemplate redisTemplate;@Overridepublic void set(String key, String value, Integer expire) {redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MINUTE);}@Overridepublic String get(String key) {return (String) redisTemplate.opsForValue().get(key);}@Overridepublic void remove(String key) {redisTemplate.delete(key);}
}
验证码接口测试
- 黑马提供的验证码服务中的Controller中有一个方法,是用来生成验证码图片的
@ApiOperation(value="生成验证信息", notes="生成验证信息")@PostMapping(value = "/pic")public CheckCodeResultDto generatePicCheckCode(CheckCodeParamsDto checkCodeParamsDto){return picCheckCodeService.generate(checkCodeParamsDto);}
- 我们使用HttpClient测试该接口
### 获取验证码图片
POST localhost:53075/checkcode/pic
- 响应结果如下,图片是以base64编码格式存储的,我们可以复制直接在浏览器中打开
{"key": "checkcode:20a2ccb511bc472ea785db14d0a547ba","aliasing": "data:image/png;base64,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"
}
- 同时在Redis图形化界面中也可以看到我们缓存的验证码
- Controller中还有一个校验验证码的方法
@PostMapping(value = "/verify")public Boolean verify(String key, String code){Boolean isSuccess = picCheckCodeService.verify(key,code);return isSuccess;}
- 我们同样使用HttpClient进行测试
### 校验验证码 POST localhost:53075/checkcode/verisfy?key=checkcode:c3dce1413f95414e943dcf0a97983fe8&code=HZCG
- 当验证码和key正确的时候,输出true
- 当验证码和key错误的时候,输出false
账号密码认证
需求分析
- 到目前为止,账号和密码认证所需要的技术、组件已开发完毕,下面实现账号密码认证,执行流程如下
账号密码认证开发
- 定义远程调用验证码服务的接口
@FeignClient(value = "checkcode")
public interface CheckCodeClient {@PostMapping(value = "/checkcode/verify")public Boolean verify(@RequestParam("key") String key,@RequestParam("code") String code);
}
- 启动类添加注解
@EnableFeignClients(basePackages = "com.xuecheng.*.feignclient")
- 完善PasswordAuthServiceImpl
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {@AutowiredXcUserMapper xcUserMapper;@AutowiredPasswordEncoder passwordEncoder;+ @Autowired
+ CheckCodeClient checkCodeClient;@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {
+ // 校验验证码
+ String checkcode = authParamsDto.getCheckcode();
+ String checkcodekey = authParamsDto.getCheckcodekey();
+ if (StringUtils.isBlank(checkcode) || StringUtils.isBlank(checkcodekey)){
+ throw new RuntimeException("验证码为空");
+ }
+ Boolean verify = checkCodeClient.verify(checkcodekey, checkcode);
+ if (!verify){
+ throw new RuntimeException("验证码输入错误");
+ }// 1. 获取账号String username = authParamsDto.getUsername();// 2. 根据账号去数据库中查询是否存在XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));// 3. 不存在抛异常if (xcUser == null) {throw new RuntimeException("账号不存在");}// 4. 校验密码// 4.1 获取用户输入的密码String passwordForm = authParamsDto.getPassword();// 4.2 获取数据库中存储的密码String passwordDb = xcUser.getPassword();// 4.3 比较密码boolean matches = passwordEncoder.matches(passwordForm, passwordDb);// 4.4 不匹配,抛异常if (!matches) {throw new RuntimeException("账号或密码错误");}// 4.5 匹配,封装返回XcUserExt xcUserExt = new XcUserExt();BeanUtils.copyProperties(xcUser, xcUserExt);return xcUserExt;}
}
- 重启服务,测试登录,若登录成功,右上角可以看到登录用户信息,同时cookie中也有jwt令牌
{% note warning no-icon %}
- 我这里遇到的问题:设置cookie一直失败,暂未解决。目前只能在控制台手动设置cookie
document.cookie = "jwt=令牌内容"
- 设置cookie的代码是前端js写的
login(){//转json串let usernameJson = JSON.stringify(this.usernamejson)console.log(usernameJson)this.formdata.username = usernameJson;let params = querystringify(this.formdata);loginSubmit(params).then(res=>{console.log(res) // 控制台输出正常console.log(res.access_token) // 控制台输出正常if(res&& res.access_token){console.log("进入到设置cookie之前") // 控制套输出正常setCookie("jwt","123",30); // 无效if(this.autologin){setCookie('jwt',res.access_token,30)}else{console.log("进入到setCookie方法上面") // 控制台输出正常setCookie('jwt',"123",0) // 无效}this.$message.success('登录成功')if(this.returnUrl){top.location=this.returnUrl}else{top.location='/'}}}).catch(error=>{if(error&&error.response&&error.response.data&&error.response.data.error_description){this.$message.error(error.response.data.error_description)}this.getCheckCode();})
}
- 对应的setCookie方法
function setCookie(name,value,Days){if(Days==0){document.cookie = name + "="+ escape (value) + ";domain=localhost;expires=0;path=/" ;}else{var exp = new Date();exp.setTime(exp.getTime() + Days*24*60*60*1000);document.cookie = name + "="+ escape (value) + ";domain=localhost;expires=" + exp.toGMTString()+";path=/";}
}
- 但是logout()调用的setCookie生效,将手动set的jwt令牌删除了
function logout(){setCookie('jwt','',-1)window.location='/'
}
- 单步调试可以看到jwt令牌应该是被成功设置到了cookie,但cookie里还是没有
- 有前端大佬可以指点一下吗
{% endnote %}
{% note pink no-icon %} - bug莫名其妙的解决了,现在登录可以成功设置cookie,右上角也可以看到登录用户信息
- 我现在怀疑我被智子入侵了,物理学不存在了
- {% psw debug花了快一天,还请教了chatGPT两个多小时,麻了 %}
{% endnote %}
微信登录
{% note info no-icon %}
- 在此特别感谢Flylightzkf提供的AppSercet
{% endnote %}
接入规范
接入流程
- 接口文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
- 微信OAuth2.0授权登录目前支持authorization_code模式,适用于拥有 server 端的应用授权。该模式整体流程为:
- 第三方发起微信授权登录请求,微信用户允许授权第三方应用后,微信会拉起应用或重定向到第三方网站,并且带上授权临时票据 code 参数;
- 通过 code 参数加上 AppID 和AppSecret等,通过 API 换取access_token;
- 通过access_token进行接口调用,获取用户基本数据资源或帮助用户实现基本操作。
- 获取access_token时序图
- 这里采用的是将微信登录二维码内嵌到自己页面,然后用户扫码登录,查看官方文档的使用说明
- 在页面yinru如下JS
http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
- 在需要使用微信登录的地方实例以下JS对象
{% note warning no-icon %}
这里的AppId和回调地址是尚硅谷提供的,所以我们必须修改认证服务的端口为8160,不然不能用(什么NTR剧情)
修改nacos中的auth-service-dev.yaml,修改端口、appid、appsecret
{% endnote %}server:port: 8160 weixin:appid: wxed9954c01bb89b47secret: a7482517235173ddb4083788de60b90e
var wxObj = new WxLogin({self_redirect:true,id:"login_container", appid: "wxed9954c01bb89b47", scope: "snsapi_login", redirect_uri: "http://localhost:8160/auth/wxLogin",state: token,style: "",href: ""});
- 这两步黑马已经帮我们完成了,现在页面上就已经有了二维码
- 参数说明
参数 | 是否必须 | 说明 |
---|---|---|
self_redirect | 否 | true:手机点击确认登录后可以在 iframe 内跳转到 redirect_uri,false:手机点击确认登录后可以在 top window 跳转到 redirect_uri。默认为 false。 |
id | 是 | 第三方页面显示二维码的容器id |
appid | 是 | 应用唯一标识,在微信开放平台提交应用审核通过后获得 |
scope | 是 | 应用授权作用域,拥有多个作用域用逗号(,)分隔,网页应用目前仅填写snsapi_login即可 |
redirect_uri | 是 | 重定向地址,需要进行UrlEncode |
state | 否 | 用于保持请求和回调的状态,授权请求后原样带回给第三方。该参数可用于防止 csrf 攻击(跨站请求伪造攻击),建议第三方带上该参数,可设置为简单的随机数加 session 进行校验 |
style | 否 | 提供"black"、"white"可选,默认为黑色文字描述。详见文档底部FAQ |
href | 否 | 自定义样式链接,第三方可根据实际需求覆盖默认样式。详见文档底部FAQ |
接入微信登录
接入分析
- 根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程
- 本项目认证服务需要做哪些事?
- 需要定义接口接收微信下发的授权码
- 收到授权码调用微信接口申请令牌
- 申请到令牌后,调用微信获取用户信息
- 获取用户信息成功,将其写入本项目的用户信息数据库
- 重定向到浏览器自动登录