SpringSecurity OAuth2实现单点登录,微信扫码登录,Redis缓存验证码---入门到实战
1. 认证授权
1.1 什么是认证授权
例如课程发布后用户通过在线学习页面点播视频进行学习。如何去记录学生的学习过程呢?要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程,如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。
认证授权模块实现平台所有用户的身份认证与用户授权功能。
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证的表现形式有:用户名密码登录,微信扫码等方式。
本案例基于黑马程序员2023版的学成在线-Plus中的第五章认证授权v3.1。
1.1.1 什么是用户认证
项目包括学生、学习机构的老师、平台运营人员三类用户,不管哪一类用户在访问项目受保护资源时都需要进行身份认证。比如:发布课程操作,需要学习机构的老师首先登录系统成功,然后再执行发布课程操作。创建订单,需要学生用户首先登录系统,才可以创建订单。如下图:
1.1.2 什么是用户授权
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。比如:用户去发布课程,系统首先进行用户身份认证,认证通过后继续判断用户是否有发布课程的权限,如果没有权限则拒绝继续访问系统,如果有权限则继续发布课程。如下图:
1.2 单点登录
用户只需要认证一次便可以在多个拥有访问权限的系统中访问,这个功能叫做单点登录。
引用百度百科:单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
如下图,用户只需要认证一次,便可以在多个拥有访问权限的系统中访问。
1.3 第三方认证
为了提高用户体验,很多网站有扫码登录的功能,如:微信扫码登录、QQ扫码登录等。扫码登录的好处是用户不用输入账号和密码,操作简便,另外一个好处就是有利于用户信息的共享,互联网的优势就是资源共享,用户也是一种资源,对于一个新网站如果让用户去注册是很困难的,如果提供了微信扫码登录将省去用户注册的成本,是一种非常有效的推广手段。
微信扫码登录其中的原理正是使用了第三方认证,如下图:
2. Spring Security 认证研究
2.1 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
Spring cloud Security: https://spring.io/projects/spring-cloud-security
2.2 认证授权入门
2.2.1 认证测试工程
下边我们使用Spring Security框架快速构建认证授权功能体系。
1. 创建一个命名为 auth普通的spring boot工程,可以连接数据库。此工程不具备认证授权的功能。
2. 创建一个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资源";}
}
- 启动工程,尝试访问http://localhost:63070/auth/r/r1 , http://localhost:63070/auth/user/52
以上测试一切正常说明此工程部署成功。
2.2.2 认证测试
1. 向 auth 认证工程集成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>
2. 重启工程,访问http://localhost:63070/auth/r/r1
自动进入/login登录页面,/login是spring security提供的,此页面有几个css样式加载会稍微慢点,如下图:
那么账号和密码是什么呢?我们需要进行安全配置,创建WebSecurityConfig配置类,继承WebSecurityConfigurerAdapter
配置用户信息
在内存配置两个用户:zhangsan、lisi
zhangsan用户拥有的权限为p1
lisi用户拥有的权限为p2
@Bean
public UserDetailsService userDetailsService() {// 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();// 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1").build());manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());return manager;
}
- 设置密码的方式
- 密码方式,暂时采用明文的方式
@Bean
public PasswordEncoder passwordEncoder() {return NoOpPasswordEncoder.getInstance();
}
- 配置安全拦截机制
- 安全拦截机制,
/r/**
开头的请求需要认证
- 安全拦截机制,
@Override
protected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/r/**").authenticated().anyRequest().permitAll().and().formLogin().successForwardUrl("/login-success");http.logout().logoutUrl("/logout");
}
重启工程
访问http://localhost:63070/auth/user/52 可以正常访问
访问http://localhost:63070/auth/r/r1 显示登录页面
账号zhangsan,密码为123,如果输入的密码不正确会认证失败,输入正确显示登录成功。
2.2.3 认证测试总结
完整代码如下:
@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("zhangsan").password("123").authorities("p1").build());manager.createUser(User.withUsername("lisi").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");}
}
配置说明:
- 通过
authorizeRequests()
方法来配置请求授权规则。 - 使用
antMatchers()
方法指定需要进行访问控制的URL
路径模式。在这里,/r/**
表示所有以/r/
开头的 URL 都需要进行授权访问。 - 使用
authenticated()
方法指定需要进行身份验证的请求。 - 使用
anyRequest()
方法配置除了/r/**
以外的所有请求都不需要进行身份验证。 - 使用
permitAll()
方法表示任何用户都可以访问不需要进行身份验证的URL
。 - 使用
formLogin()
方法配置登录页表单认证,其中successForwardUrl()
方法指定登录成功后的跳转页面。 - 使用
logout()
方法配置退出登录,其中logoutUrl()
方法指定退出登录的URL
。
2.2.4 授权测试
用户认证通过去访问系统资源时spring security进行授权控制,判断用户是否有该资源的访问权限,如果有则继续访问,如果没有则拒绝访问。下边测试授权功能:
- 配置用户拥有哪些权限。
在WebSecurityConfig类配置zhangsan拥有p1权限,lisi拥有p2权限。在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;
}
指定资源与权限的关系
什么是系统的资源?
比如:查询一个用户的信息,用户信息就是系统的资源,要访问资源需要通过==URL,所以我们在controller中定义的每个http==的接口就是访问资源的接口。
下边在controller中使用==@PreAuthorize(“hasAuthority(’ ')”)配置/r/r1需要p1权限,/r/r2==需要p2权限。
hasAuthority(‘p1’)表示拥有p1权限方可访问。
代码如下:
@RequestMapping("/r/r1")
+ @PreAuthorize("hasAnyAuthority('p1')")public String r1() {return "访问r1资源";}@RequestMapping("/r/r2")
+ @PreAuthorize("hasAuthority('p2')")public String r2() {return "访问r2资源";}
重启工程
访问/r/r1,使用zhangsan登录可以正常访问,因为在/r/r1的方法上指定了权限p1,zhangsan用户拥有权限p1,所以可以正常访问。
访问/r/r1,使用lisi登录则拒绝访问,由于lisi用户不具有权限p1需要拒绝访问
注意:如果controller上不加==@PreAuthorize==,此方法没有授权控制。
2.2.5 授权测试总结
整理授权的过程见下图所示:
注意:在controller中使用*@PreAuthorize(“hasAuthority(’ ')”)*配置权限
2.2.6 工作原理
通过测试认证和授权两个功能,我们了解了Spring Security的基本使用方法,下边了解它的工作流程。
Spring Security所解决的问题就是_安全访问控制_,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源。根据前边知识的学习,可以通过Filter或AOP等技术来实现,Spring Security对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功能的实现主要是由一系列过滤器链相互配合完成。
下面介绍过滤器链中主要的几个过滤器及其作用:
SecurityContextPersistenceFilter 这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;
UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,这些都可以根据需求做相关改变;
FilterSecurityInterceptor 是用于保护web资源的,使用AccessDecisionManager对当前用户进行授权访问,前面已经详细介绍过了;
ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。
Spring Security的执行流程如下:
\1. 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
\2. 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证
\3. 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。
\4. SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。
\5. 可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。
2.3 什么是OAuth2
2.3.1 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
表示:资源服务器校验令牌通过后,提供受保护的资源
2.3.2 OAuth2在本项目的应用
- OAuth2是一个标准的开放的授权协议,应用程序可以根据自己的需求去使用
- 本项目使用OAuth2实现如下目标
- 学成在线访问第三方系统的资源
- 本项目要接入微信扫码登录,所以本项目要是用OAuth2协议访问微信中的用户信息
- 外部系统访问学成在线的资源
- 同样当第三方系统想要访问学成在线网站的资源,也可以基于OAuth2协议来访问用户信息
- 学成在线前端(客户端)访问学成在线微服务的资源
- 本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议
- 学成在线访问第三方系统的资源
2.3.3 OAuth2的授权模式
- Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式。前面举的微信扫码登录的例子就是基于授权码模式。
- 这四种模式中,授权码模式和密码模式应用较多,这里使用Spring Security演示授权码模式、密码模式。
2.3.3.1 授权码模式
- OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,然后通过令牌去获取资源
- 授权码模式简单理解就是使用授权码去获取令牌,要想获取令牌,首先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取
- 下图是授权码模式的交互图
- 用户打开浏览器
- 通过浏览器访问客户端
- 通过浏览器想认证服务请求授权(用户扫描二维码)
- 请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址
- 认证服务向资源拥有者返回授权页面
- 资源拥有者亲自授权同意(用户点击
同意登录
) - 通过浏览器向认证服务发送授权同意
- 认证服务向客户端地址重定向,并携带授权码
- 客户端收到授权码
- 客户端携带授权码向认证服务申请令牌
- 认证服务向客户端颁发令牌
2.3.3.2 授权码模式测试
要想测试授权码模式,首先要配置授权服务,即上图中的认证服务器,需要配置授权服务及认证服务(令牌策略)
在config中创建AuthorizationService.java (授权服务器)和 TokenConfig.java(令牌策略配置)
AuthorizationService
使用@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 (在WebSecurityConfig中配置)
@EnableWebSecurity @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter {@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}···}
到这里WebSecurityConfig里面已经配置了密码方式,OAuth2,安全拦截机制,其中完整代码如下:
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {// OAuth2@Beanpublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}// 密码设置为明文模式@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}//配置安全拦截机制@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests().antMatchers("/r/**").authenticated().anyRequest().permitAll().and().formLogin().successForwardUrl("/login-success");http.logout().logoutUrl("/logout");}}
重启认证服务
get请求获取授权码
地址: http://localhost:63070/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn
参数列表如下:
• client_id:客户端准入标识。
• response_type:授权码模式固定为code。
• scope:客户端权限。
• redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。
输入账号zhangsan、密码123登录成功,输入/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn
显示授权页面
请求成功,重定向至http://www.51xuecheng.cn/?code=授权码,比如:http://www.51xuecheng.cn/?code=Wqjb5H
使用httpclient工具post申请令牌
/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=授权码&redirect_uri=http://www.51xuecheng.cn/
参数列表如下
• client_id:客户端准入标识。
• client_secret:客户端秘钥。
• grant_type:授权类型,填写authorization_code,表示授权码模式
• code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
• redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
httpclient脚本如下:
### 授权码模式 ### 第一步申请授权码(浏览器请求)/oauth/authorize?client_id=c1&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn ### 第二步申请令牌 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=CTvCrB&redirect_uri=http://www.51xuecheng.cn
申请令牌成功如下所示:
{"access_token": "368b1ee7-a9ee-4e9a-aae6-0fcab243aad2","token_type": "bearer","refresh_token": "3d56e139-0ee6-4ace-8cbe-1311dfaa991f","expires_in": 7199,"scope": "all" }
说明:
1、access_token,访问令牌,用于访问资源使用。
2、token_type,bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时需要在head中加入bearer 空格 令牌内容
3、refresh_token,当令牌快过期时使用刷新令牌可以再次生成令牌。
4、expires_in:过期时间(秒)
5、scope,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。
2.3.3.4 密码模式
密码模式相对授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如下图:
资源拥有者提供账号和密码
客户端向认证服务申请令牌,请求中携带账号和密码
认证服务校验账号和密码正确颁发令牌。
开始测试:
POST请求获取令牌
/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=shangsan&password=123
参数列表如下:
• client_id:客户端准入标识。
• client_secret:客户端秘钥。
• grant_type:授权类型,填写password表示密码模式
• username:资源拥有者用户名。
• password:资源拥有者密码。
授权服务器将令牌(access_token)发送给client
使用httpclient进行测试
### 密码模式 POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123
返回实例:
{"access_token": "368b1ee7-a9ee-4e9a-aae6-0fcab243aad2","token_type": "bearer","refresh_token": "3d56e139-0ee6-4ace-8cbe-1311dfaa991f","expires_in": 6806,"scope": "all" }
注意:这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我们自己开发的情况下。
2.4 JWT令牌
2.4.1 什么是JWT
JSON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任,它可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止内容篡改。官网:https://jwt.io/
使用JWT可以实现无状态认证,什么是无状态认证?
传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。
如下图,当用户访问应用服务,每个应用服务都会去服务器查看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令牌的第二部分
- 为什么JWT可以防止篡改?
第三部分使用签名算法对第一部分和第二部分的内容进行签名,常见的签名算法是HS526,常见的还有MD5、SHA等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容,那么服务器验证前面就会失败,要想保证签名正确,必须保证内容、密钥与签名前一致
从上图中可以看出,认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造JWT令牌
JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密、解密,非对称加密效率低,但相比较于对称加密更加安全
2.4.2 测试生成JWT令牌
在认证服务(TokenConfig)中配置JWT令牌服务,即可实现生成JWT格式的令牌
@Configuration
public class TokenConfig {private String SIGNING_KEY = "mq123";@AutowiredTokenStore tokenStore;@Autowiredprivate JwtAccessTokenConverter accessTokenConverter;@Beanpublic TokenStore tokenStore() {return new JwtTokenStore(accessTokenConverter());}// // 内存存储令牌
// @Bean
// public TokenStore tokenStore() {// //使用内存存储令牌(普通令牌)
// return new InMemoryTokenStore();
// }// JWT令牌@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=zhangsan&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": ["res1"],"user_name": "zhangsan","scope": ["all"],"active": true,"exp": 1664371780,"authorities": ["p1"],"jti": "f0a3cdeb-399d-48f0-8804-eca638ad8857","client_id": "c1"
}
2.4.3 携带令牌访问资源服务
拿到了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.java
@Configuration
public class TokenConfig {private String SIGNING_KEY = "mq123";// JWT令牌@Beanpublic JwtAccessTokenConverter accessTokenConverter(){JwtAccessTokenConverter converter = new JwtAccessTokenConverter();converter.setSigningKey(SIGNING_KEY);return converter;}// 令牌选择@Beanpublic TokenStore tokenStore() {return new JwtTokenStore(accessTokenConverter());}
}
- 在资源服务content-api中添加ResourceServerConfig.java
@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);}// 安全http配置@Overridepublic void configure(HttpSecurity http) throws Exception {http.csrf().disable() // 禁用 CSRF 保护.authorizeRequests() //配置对请求的授权策略.antMatchers("/r/**", "/course/**").authenticated() // 指定 "/r/" 和 "/course/" 这两个路径需要进行身份认证才能访问。.anyRequest().permitAll(); // 允许所有其他请求(除了上面指定的路径之外)都可以被访问,不需要进行身份认证。}
}
- 说明: 这里的 /course/ 是资源服务中的请求前缀,当时在nacos中已经配置好
重启内容管理服务,使用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" }
2.4.4 测试获取用户身份
jwt令牌中记录了用户身份信息,当客户端携带jwt访问资源服务,资源服务验签通过后将前两部分的内容还原即可取出用户的身份信息,并将用户身份信息放在了SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份。
还以查询课程接口为例,进入查询课程接口的代码中,添加获取用户身份的代码
@ApiOperation("根据课程id查询课程基础信息")
@GetMapping("/course/{courseId}")
public CourseBaseInfoDto getCourseBaseById(@PathVariable("courseId") Long courseId){//取出当前用户身份Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();System.out.println(principal);return courseBaseInfoService.getCourseBaseInfo(courseId);
}
测试时需要注意:
首先在资源服务配置中指定安全拦截机制 /course/开头的请求需要认证,即请求
/course/{courseId}
接口需要携带jwt令牌且签证通过。认证服务生成jwt令牌将用户身份信息写入令牌,目前还是将用户信息硬编码并暂放在内存中。(前文认证测试时在
WebSecurityConfig
已经配置好)
@Bean
public UserDetailsService userDetailsService() {//这里配置用户信息,这里暂时使用这种方式将用户存储在内存中InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1").build());manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());return manager;
}
我们在使用密码模式生成jwt令牌时用的是zhangsan的信息,所以jwt令牌中存储了zhangsan的信息,那么在资源服务中应该取出zhangsan的信息才对。
清楚了以上内容,下边重启内容管理服务,跟踪取到的用户身份是否正确。
当前用户身份为:zhangsan
2.5 网关鉴权
2.5.1 什么是网关鉴权
到目前为止,测试通过了认证服务颁发的JWT令牌,客户端携带JWT访问资源服务,资源服务会对JWT的合法性进行验证,如下图
仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示:
所有访问微服务的请求都要经过网关,在网关进行用户身份的认证可以将很多非法的请求拦截到微服务以外,这叫做网关认证。
下边需要明确网关的职责:
网站白名单维护 : 针对不用认证的URL全部放行。
校验jwt的合法性 : 除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。
网关负责授权吗? 网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口。
2.5.2 实现网关认证
下边实现网关认证,实现以下职责:
网站白名单维护
校验jwt的合法性。
下面是详细步骤:
- 在网关工程添加依赖
<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包下
GatewayAuthFilter
@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;}
}
RestErrorResponse
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;}
}
SecurityConfig
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {//安全拦截配置@Beanpublic SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {return http.authorizeExchange().pathMatchers("/**").permitAll().anyExchange().authenticated().and().csrf().disable().build();}
}
TokenConfig
@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;}
}
- 在网关的resources中配置白名单文件
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": "认证令牌无效" }
注意:网关鉴权功能调试通过后,由于目前还没有开发认证功能,前端请求网关的URL不在白名单时,会出现
没有认证
错误,所以暂时在白名单中添加全部放行配置,待认证功能开发完成后,再屏蔽全部放行配置/**=暂时全部放开 /auth/**=认证地址 /content/open/**=内容管理公开访问接口 /media/open/**=媒资管理公开访问接口
由于是在网关处进行令牌校验,所以在微服务处不再校验令牌的合法性,修改内容管理服务
content-api
的ResouceServerConfig
类,屏蔽authenticated()
。@Overridepublic void configure(HttpSecurity http) throws Exception {http.csrf().disable().authorizeRequests() // .antMatchers("/r/**","/course/**").authenticated()//所有/r/**的请求必须认证通过.anyRequest().permitAll();}
到这里资源服务content-api中ResourceServerConfig.java完整代码如下:
@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);}// 安全http配置@Overridepublic void configure(HttpSecurity http) throws Exception {http.csrf().disable() // 禁用 CSRF 保护.authorizeRequests() //配置对请求的授权策略//.antMatchers("/r/**", "/course/**").authenticated() // 指定 "/r/" 和 "/course/" 这两个路径需要进行身份认证才能访问。.anyRequest().permitAll(); // 允许所有其他请求(除了上面指定的路径之外)都可以被访问,不需要进行身份认证。} }
3. 用户认证
3.1 需求分析
至此我们了解了使用Spring Security进行认证授权的过程,本节实现用户认证功能。
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。
本项目也要支持多种认证试。
3.2 连接用户中心数据库
3.2.1 连接数据库认证
基于的认证流程在研究Spring Security过程中已经测试通过,到目前为止用户认证流程如下:
认证所需要的用户信息存储在xc_user库中,之前我们是将用户信息硬编码,放在内存中的,现在我们要从数据库来查询用户信息来登录(下文会把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
类型的用户信息即可,框架调用loadUserByUsername()
方法拿到用户信息之后是如何执行的,见下图:
- 首先屏蔽原来定义的
UserDetailsService
。(在WebSecurityConfig
中之前定义的)
// @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;
// }
- 下面自己在auth工程的service中定义
UserDetailsService
,这里的mapper都用Mybatis-Plus生成
@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();}
}
数据库中的密码加过密的,用户输入的密码是明文,我们需要将
WebSecurityConfig
修改密码格式器PasswordEncoder
,原来使用的是NoOpPasswordEncoder
,它是通过明文方式比较密码,现在我们修改为BCryptPasswordEncoder
,它是将用户输入的密码编码为BCrypt
格式与数据库中的密码进行比对。如下:
@Beanpublic 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
修改数据库中的密码为
Bcrypt
格式,并且记录明文密码,稍后申请令牌时需要。由于修改密码编码方式还需要将(授权服务的
AuthorizationServer
)客户端的密钥更改为Bcrypt
格式。@Overridepublic void configure(ClientDetailsServiceConfigurer clients)throws Exception {clients.inMemory()// 使用in-memory存储.withClient("XcWebApp")// client_id // .secret("secret")//客户端密钥.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://www.51xuecheng.cn");}
现在重启认证服务。
- 下边使用httpclient进行测试:
### 密码模式 POST {{auth_host}}/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=stu1&password=111111
输入正确的账号和密码,申请令牌成功。
输入错误的密码,报错:
{"error": "invalid_grant","error_description": "用户名或密码错误" }
- 输入错误的账号,报错:
{"error": "unauthorized","error_description": "UserDetailsService returned null, which is an interface contract violation" }
3.2.2 拓展Security 用户的身份信息
用户表中存储了用户的账号
、手机号
、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();
}
如何扩展Spring Security的用户身份信息呢?
在认证阶段DaoAuthenticationProvider会调用UserDetailsService查询用户的信息,这里是可以获取到齐全的用户信息。
由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路
- 扩展UserDetails,时期包括更多的自定义属性
- 扩展username的内容,例如存入Json数据作为username的内容
相较而言,方案2比较简单,而且也不用破坏UserDetails的结构,这里采用方案二
下面是具体步骤:
- 修改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格式的内容,转换为用户对象去使用
3.2.3 资源服务获取用户身份
下边编写一个工具类在各个微服务中去使用,获取当前登录用户的对象。
在``content-api中定义
SecurityUtil`此类:
@Slf4j
public class SecurityUtil {public static XcUser getUser() {try {Object principalObj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();if (principalObj instanceof String) {//取出用户身份信息String principal = principalObj.toString();//将json转成对象XcUser user = JSON.parseObject(principal, XcUser.class);return user;}} catch (Exception e) {log.error("获取当前登录用户身份出错:{}", e.getMessage());e.printStackTrace();}return null;}@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);}
3.3 如何支持认证方式多样化
3.3.1 统一认证入口
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。基于当前研究的Spring Security认证流程如何支持多样化的认证方式呢?
1、支持账号和密码认证
采用OAuth2协议的密码模式即可实现。
2、支持手机号加验证码认证
用户认证提交的是手机号和验证码,并不是账号和密码。
3、微信扫码认证
基于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
DaoAuthenticationProviderCustom
@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 {// 里面啥也不写就不会校验密码了}
}
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"));}}
}
- 同时也需要修改
WebSecurityConfig
类,指定DaoAuthenticationProviderCustom
@Autowired
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;
// 配置密码验证方式
@Override
protected 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接口去进行各种方式的认证。
定义dto用户信息,为了扩展性让它继承XcUser(注:这里最好不要直接用XcUser类,理由在之前的文章也说过,万一我们需要扩展一些其他的用户信息,那么我们直接修改XcUser类是不现实的,因为XcUser类对应的是数据库中的表。所以即使我们要使用XcUser类作为返回类型,也最好是让一个其他的类继承XcUser)
@Data
public class XcUserExt extends XcUser {}
- 定义认证Service接口
/*** 认证Service*/
public interface AuthService {/*** 认证方法* @param authParamsDto 认证参数* @return 用户信息*/XcUserExt execute(AuthParamsDto authParamsDto);
}
定义AuthService接口的实现类,即各种认证方式
定义AuthService接口的实现类,即各种认证方式
一个接口的多种实现,我们依靠
beanName
来做区分,例如这里的password_authservice
,见名知意就知道是密码登录方式
@Service("password_authservice") public class PasswordAuthServiceImpl implements AuthService {@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {return null;} }
- 微信扫码方式
@Service("wx_authservice") public class WxAuthServiceImpl implements AuthService {@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {return null;} }
修改
UserDetailsImpl
的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();}
到此我们基于Spring Security认证流程修改为如下
3.3.2 实现账号密码认证
上节定义了AuthService认证接口,下边实现该接口实现账号密码认证
@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;}
}
修改UserDetails
中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"}
3.3.3 Redis实现验证码服务
在认证时一般都需要输入验证码,验证码有什么用?
验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。
为了保护系统的安全在一些比较重要的操作都需要验证码。
验证码的类型也有很多:图片、语音、手机短信验证码等。
下面导入黑马的验证码模块:
- 定义nacos配置文件
server:servlet:context-path: /checkcodeport: 63075
- 配置
redis-dev.yaml
spring: redis:host: 192.168.101.65port: 6379password: redisdatabase: 0lettuce:pool:max-active: 20max-idle: 10min-idle: 0timeout: 10000#redisson:#配置文件目录#config: classpath:singleServerConfig.yaml
- 新增网关的nacos配置
- id: auth-serviceuri: lb://auth-servicepredicates:- Path=/auth/**
- id: checkcodeuri: lb://checkcodepredicates:- Path=/checkcode/**
- 验证码模块的bootstrap.yaml
spring:application:name: checkcodecloud:nacos:server-addr: 192.168.101.65:8848discovery:namespace: ${spring.profiles.active}group: xuecheng-plus-projectconfig:namespace: ${spring.profiles.active}group: xuecheng-plus-projectfile-extension: yamlrefresh-enabled: trueshared-configs:- data-id: swagger-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: true- data-id: logging-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: true- data-id: redis-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: trueprofiles:active: dev
- 在验证码模块中引入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>
- 在验证码模块中(xuecheng-plus-checkcod),
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中
generatePicCheckCode
方法,是用来生成验证码图片的
@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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"
}
- Controller中
verify
方法,是用来校验验证码的
@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
3.3.4 账号密码认证
3.3.4.1 需求分析
效果图:
![\[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DRmedWBQ-1682427813463)(C:/Users/%E9%9A%8F%E5%90%9B/AppData/Roaming/Typora/typora-user-images/image-20230425140045733.png)\]](https://img-blog.csdnimg.cn/f1b4ec7e24e0499687f9abc8e1486794.png)
执行流程如下:
3.3.4.2 auth工程账号密码认证开发
- 在auth工程中定义远程调用验证码服务的接口
@FeignClient(value = "checkcode",fallbackFactory = CheckCodeClientFactory.class)@RequestMapping("/checkcode")
public interface CheckCodeClient {@PostMapping(value = "/verify")public Boolean verify(@RequestParam("key") String key,@RequestParam("code") String code);}
CheckCodeClientFactory
熔断降级实现方案:
@Slf4j
@Component
public class CheckCodeClientFactory implements FallbackFactory<CheckCodeClient> {@Overridepublic CheckCodeClient create(Throwable throwable) {return new CheckCodeClient() {@Overridepublic Boolean verify(String key, String code) {log.debug("调用验证码服务熔断异常:{}", throwable.getMessage());return null;}};}
}
- 启动类添加注解
@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;}
}
到这里,PasswordAuthServiceImpl
完整的代码如下:
@Slf4j
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {@AutowiredXcUserMapper xcUserMapper;@AutowiredPasswordEncoder passwordEncoder;@AutowiredCheckCodeClient 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;}
}
3.3.4.3 账号密码认证测试
首先测试验证码,分别输入正确的验证码和错误的验证码进行测试
输入正确的账号密码和错误的账号密码进行测试
登录成功将jwt令牌存储cookie.
测试自动登录
勾选自动登录cookie生成时间为30天,不勾选自动登录关闭浏览器窗口后自动删除cookie。
4. 微信扫码登录
4.1 接入规范
4.1.1 接入流程
微信扫码登录基于OAuth2协议的授权码模式,
接口文档:
https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
流程如下:
第三方应用获取access_token令牌后即可请求微信获取用户的信息,成功获取到用户的信息表示用户在第三方应用认证成功。
4.1.2 请求获取授权码
步骤1:在页面中先引入如下 JS 文件(支持https):
http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
步骤2:在需要使用微信登录的地方实例以下 JS 对象:
var obj = new WxLogin({self_redirect:true,id:"login_container", appid: "", scope: "", redirect_uri: "",state: "",style: "",href: ""});
4.1.3 接入微信登录
4.1.3.1 接入分析
根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程
4.1.3.2 定义接口定义接口
- 定义
WxLoginController
类,如下:
@Slf4j
@Controller
public class WxLoginController {@AutowiredWxAuthServiceImpl wxAuthService;@RequestMapping("/wxLogin")public String wxLogin(String code, String state) throws IOException {log.debug("微信扫码回调,code:{},state:{}",code,state);XcUser xcUser = wxAuthService.wxAuth(code);if(xcUser==null){return "redirect:http://localhost/error.html";}String username = xcUser.getUsername();return "redirect:http://localhost/sign.html?username="+username+"&authType=wx";}
}
- 定义微信认证的service
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService {@AutowiredXcUserMapper xcUserMapper;public XcUser wxAuth(String code) {//TODO: 获取access_token//TODO: 获取用户信息// 这里先用个假数据XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));//TODO: 添加用户信息到数据库return xcUser;}/*** 微信扫码认证,不需要校验密码和验证码** @param authParamsDto 认证参数* @return*/@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {// 账号String username = authParamsDto.getUsername();XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));if (user == null) {throw new RuntimeException("账号不存在");}XcUserExt xcUserExt = new XcUserExt();BeanUtils.copyProperties(user, xcUserExt);return xcUserExt;}
}
4.1.3.3 申请令牌
接下来请求微信申请令牌。
使用
restTemplate
请求微信,配置RestTemplate bean
在启动类配置
restTemplate
@EnableFeignClients
@SpringBootApplication
public class AuthApplication {public static void main(String[] args) {SpringApplication.run(AuthApplication.class, args);}@BeanRestTemplate restTemplate(){RestTemplate restTemplate = new RestTemplate(new OkHttp3ClientHttpRequestFactory());return restTemplate;}
}
- 定义与微信认证的service接口:
public interface WxAuthService {public XcUser wxAuth(String code);}
- 让
WxAuthServiceImpl
实现WxAuthService
和AuthService
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService, WxAuthService {@AutowiredXcUserMapper xcUserMapper;@Overridepublic XcUser wxAuth(String code) {//TODO: 获取access_token//TODO: 获取用户信息// 这里先用个假数据XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));//TODO: 添加用户信息到数据库return xcUser;}/*** 微信扫码认证,不需要校验密码和验证码** @param authParamsDto 认证参数* @return*/@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {// 账号String username = authParamsDto.getUsername();XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));if (user == null) {throw new RuntimeException("账号不存在");}XcUserExt xcUserExt = new XcUserExt();BeanUtils.copyProperties(user, xcUserExt);return xcUserExt;}
}
在
WxAuthServiceImpl
类中定义申请令牌和获取用户信息的私有方法,刚刚我们是用的一个死数据登录的,现在我们要从微信获取真实的用户信息申请令牌
getAccess_token()
:
private Map<String, String> getAccess_token(String code) {// 1. 请求路径模板,参数用%s占位符String url_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";// 2. 填充占位符:appid,secret,codeString url = String.format(url_template, appid, secret, code);// 3. 远程调用URL,POST方式(详情参阅官方文档)ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);// 4. 获取相应结果,响应结果为json格式String result = exchange.getBody();// 5. 转为mapMap<String, String> map = JSON.parseObject(result, Map.class);return map;
}
获取用户信息getUserinfo()
private Map<String,String> getUserinfo(String access_token,String openid) {String wxUrl_template = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s";//请求微信地址String wxUrl = String.format(wxUrl_template, access_token,openid);log.info("调用微信接口申请access_token, url:{}", wxUrl);ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null, String.class);//防止乱码进行转码String result = new String(exchange.getBody().getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);log.info("调用微信接口申请access_token: 返回值:{}", result);Map<String,String> resultMap = JSON.parseObject(result, Map.class);return resultMap;
}
测试获取用户信息
1、在获取用户信息处打断点
2、进入http://www.51xuecheng.cn/wxsign.html
3、手机扫码授权
4.1.3.4 保存用户信息
向数据库保存用户信息,如果用户不存在,则将其保存在数据库
下面是具体步骤:
- 在
WxAuthServiceImpl
中定义方法addWxUser()
:
@Transactional
public XcUser addWxUser(Map<String, String> user_info_map){// 1. 获取用户唯一标识:unionid作为用户的唯一表示String unionid = user_info_map.get("unionid");// 2. 根据唯一标识,判断数据库是否存在该用户XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, unionid));// 2.1 存在,则直接返回if (xcUser != null){return xcUser;}// 2.2 不存在,新增xcUser = new XcUser();// 2.3 设置主键String uuid = UUID.randomUUID().toString();xcUser.setId(uuid);// 2.4 设置其他数据库非空约束的属性xcUser.setUsername(unionid);xcUser.setPassword(unionid);xcUser.setWxUnionid(unionid);xcUser.setNickname(user_info_map.get("nickname"));xcUser.setUserpic(user_info_map.get("headimgurl"));xcUser.setName(user_info_map.get("nickname"));xcUser.setUtype("101001"); // 学生类型xcUser.setStatus("1");xcUser.setCreateTime(LocalDateTime.now());// 2.5 添加到数据库xcUserMapper.insert(xcUser);// 3. 添加用户信息到用户角色表XcUserRole xcUserRole = new XcUserRole();xcUserRole.setId(uuid);xcUserRole.setUserId(uuid);xcUserRole.setRoleId("17");xcUserRole.setCreateTime(LocalDateTime.now());xcUserRoleMapper.insert(xcUserRole);return xcUser;
}
- 在
WxAuthServiceImpl
调用addWxUser()
方法,调用@Transactional
事务方法,注入自身
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService, WxAuthService {@AutowiredXcUserMapper xcUserMapper;@AutowiredXcUserRoleMapper xcUserRoleMapper;@AutowiredWxAuthServiceImpl wxAuthService;@AutowiredRestTemplate restTemplate;@Value("${weixin.appid}")String appid;@Value("${weixin.secret}")String secret;@Overridepublic XcUser wxAuth(String code) {// 1. 获取access_tokenMap<String, String> access_token_map = getAccess_token(code);String accessToken = access_token_map.get("access_token");// 2. 获取用户信息String openid = access_token_map.get("openid");Map<String, String> user_info_map = getUserInfo(accessToken, openid);// 3. 添加用户信息到数据库XcUser xcUser = wxAuthService.addWxUser(user_info_map);return xcUser;}
- Controller具体实现:
@RequestMapping("/wxLogin")
public String wxLogin(String code, String state) throws IOException {log.debug("微信扫码回调,code:{},state:{}",code,state);XcUser user = wxAuthService.wxAuth(code);if(user==null){return "redirect:http://localhost/error.html";}String username = user.getUsername();return "redirect:http://localhost/sign.html?username="+username+"&authType=wx";
}
到这里,WxAuthServiceImpl
的完整代码如下:
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService, WxAuthService {@AutowiredXcUserMapper xcUserMapper;@AutowiredXcUserRoleMapper xcUserRoleMapper;@AutowiredWxAuthServiceImpl wxAuthService;@AutowiredRestTemplate restTemplate;@Value("${weixin.appid}")String appid;@Value("${weixin.secret}")String secret;@Overridepublic XcUser wxAuth(String code) {// 1. 获取access_tokenMap<String, String> access_token_map = getAccess_token(code);String accessToken = access_token_map.get("access_token");// 2. 获取用户信息String openid = access_token_map.get("openid");Map<String, String> user_info_map = getUserInfo(accessToken, openid);// 3. 添加用户信息到数据库XcUser xcUser = wxAuthService.addWxUser(user_info_map);return xcUser;}/*** 微信扫码认证,不需要校验密码和验证码** @param authParamsDto 认证参数*/@Overridepublic XcUserExt execute(AuthParamsDto authParamsDto) {// 账号String username = authParamsDto.getUsername();XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));if (user == null) {throw new RuntimeException("账号不存在");}XcUserExt xcUserExt = new XcUserExt();BeanUtils.copyProperties(user, xcUserExt);return xcUserExt;}private Map<String, String> getAccess_token(String code) {// 1. 请求路径模板,参数用%s占位符String url_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";// 2. 填充占位符:appid,secret,codeString url = String.format(url_template, appid, secret, code);// 3. 远程调用URL,POST方式(详情参阅官方文档)ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);// 4. 获取响应结果,响应结果为json格式String result = exchange.getBody();// 5. 转为mapMap<String, String> map = JSON.parseObject(result, Map.class);return map;}private Map<String, String> getUserInfo(String access_token, String openid) {// 1. 请求路径模板,参数用%s占位符String url_template = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s";// 2. 填充占位符,access_token和openidString url = String.format(url_template, access_token, openid);// 3. 远程调用URL,GET方式(详情参阅官方文档)ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.GET, null, String.class);// 4. 获取响应结果,JSON格式String result = exchange.getBody();// 4.1 需要转码result = new String(result.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);// 5. 转为mapMap<String, String> map = JSON.parseObject(result, Map.class);return map;}@Transactionalpublic XcUser addWxUser(Map<String, String> user_info_map) {// 1. 获取用户唯一标识:unionid作为用户的唯一表示String unionid = user_info_map.get("unionid");// 2. 根据唯一标识,判断数据库是否存在该用户XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, unionid));// 2.1 存在,则直接返回if (xcUser != null) {return xcUser;}// 2.2 不存在,新增xcUser = new XcUser();// 2.3 设置主键String uuid = UUID.randomUUID().toString();xcUser.setId(uuid);// 2.4 设置其他数据库非空约束的属性xcUser.setUsername(unionid);xcUser.setPassword(unionid);xcUser.setWxUnionid(unionid);xcUser.setNickname(user_info_map.get("nickname"));xcUser.setUserpic(user_info_map.get("headimgurl"));xcUser.setName(user_info_map.get("nickname"));xcUser.setUtype("101001"); // 学生类型xcUser.setStatus("1");xcUser.setCreateTime(LocalDateTime.now());// 2.5 添加到数据库xcUserMapper.insert(xcUser);// 3. 添加用户信息到用户角色表XcUserRole xcUserRole = new XcUserRole();xcUserRole.setId(uuid);xcUserRole.setUserId(uuid);xcUserRole.setRoleId("17");xcUserRole.setCreateTime(LocalDateTime.now());xcUserRoleMapper.insert(xcUserRole);return xcUser;}
}
5. 用户授权
5.1 RBAC
如何实现授权?业界通常基于RBAC实现授权。
RBAC分为两种方式:
1. 基于角色的访问控制(Role-Based Access Control)
(但是如果现在的需求是:总经理和部门经理都可以查询报表和工资,那么此时就需要修改逻辑判断)
if(主体.hasRole("总经理角色ID")){//TODO: 查询报表//TODO: 查询工资
}
修改:
if(主体.hasRole("总经理角色ID") || 主体.hasRole("部门经理角色ID")){//TODO: 查询报表//TODO: 查询工资
}
2. 基于资源的访问控制(Resource-Based Access Control)
if(主体.hasPermission("查询工资权限标识")){//TODO: 查询工资
}
优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理,也不需要修改授权代码,系统可扩展性强
5.2 资源服务授权
在需要授权的接口处使用==@PreAuthorize("hasAuthority('权限标识符')")
==进行控制
@ApiOperation("课程查询接口")
@PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamDto queryCourseParams) {PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParams);return result;
}
如果当前用户没有请求该接口的权限,则会抛异常
org.springframework.security.access.AccessDeniedException: 不允许访问
由于该异常是Spring Security框架抛出的,而我们的统一异常处理器是在base工程中,我们不想让base工程依赖Spring Security,所以采取下面的解决方案
5.2.1 在统一异常处理器中解析异常信息
我们只判断拿到的异常信息是否为不允许访问
,如果是,则提示没有操作此功能的权限
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception exception) {log.error("系统异常:{}", exception.getMessage());if ("不允许访问".equals(exception.getMessage()))return new RestErrorResponse("您没有权限操作此功能");return new RestErrorResponse(exception.getMessage());
}
这里我们可以重启服务测试
5.3 查询用户权限
5.3.1 用户授权相关的数据表模型
如何给用户分配权限呢?查看数据库中的表结构
xc_user
:用户表,存储了系统用户信息xc_user_role
:用户角色表,一个用户可拥有多个角色,一个角色可被多个用户拥有xc_role
:角色表,存储了系统的角色类型,角色类型包括:学生、老师、管理员、教学管理员、超级管理员xc_permission
:角色权限表,一个角色可拥有多个权限,一个权限可被多个角色拥有xc_menu
:权限菜单表,里面记录了各种操作的权限code
步骤:
查询用户的id
查询用户所拥有的角色
查询用户所拥有的权限
例子:
SELECT * FROM xc_menu WHERE id IN(
SELECT menu_id FROM xc_permission WHERE role_id IN(
SELECT role_id FROM xc_user_role WHERE user_id = ‘49’
)
)
- 给用户分配权限
1)添加权限
查询用户的id
查询权限的id
查询用户的角色,如果没有角色需要先给用户指定角色
向角色权限表添加记录
2)删除用户权限
本项目是基于角色分配权限,如果要删除用户的权限可以给用户换角色,那么新角色下的权限就是用户的权限;如果不换用户的角色可以删除角色下的权限即删除角色权限关系表相应记录,这样操作是将角色下的权限删除,属于该角色的用户都将删除此权限。
5.3.2 实现查询用户权限
使用Spring Security进行授权,首先在生成jwt前会查询用户的权限,如下图:
接下来修改UserDetailsImpl
和PasswordAuthServiceImpl
,从数据库查询用户的权限,查询权限的SQL代码如下
SELECT * FROM xc_menu WHERE id IN (SELECT menu_id FROM xc_permission WHERE role_id IN ( SELECT role_id FROM xc_user_role WHERE user_id = '52' )
)
下面是具体步骤:
- 定义mapper接口
public interface XcMenuMapper extends BaseMapper<XcMenu> {@Select("SELECT * FROM xc_menu WHERE id IN (SELECT menu_id FROM xc_permission WHERE role_id IN ( SELECT role_id FROM xc_user_role WHERE user_id = #{userId} ))")List<XcMenu> selectPermissionByUserId(@Param("userId") String userId);
}
修改
PasswordAuthServiceImpl
- 首先确保在XcUserExt中添加用户权限
@Data public class XcUserExt extends XcUser {//用户权限List<String> permissions = new ArrayList<>(); }
修改UserDetailsImpl
类中的getUserPrincipal
方法,查询权限信息,并设置
public UserDetails getUserPrincipal(XcUserExt user) {+ // 获取用户id
+ String userId = user.getId();
+ // 根据用户id查询用户权限
+ List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(userId);
+ ArrayList<String> permissions = new ArrayList<>();
+ // 没权限,给一个默认的
+ if (xcMenus.isEmpty()) {+ permissions.add("test");
+ } else {+ // 获取权限,加入到集合里
+ xcMenus.forEach(xcMenu -> {+ permissions.add(xcMenu.getCode());
+ });
+ }
+ // 设置权限
+ user.setPermissions(permissions);
- String[] authorities = {"test"};
+ String[] authorities = permissions.toArray(new String[0]);String password = user.getPassword();user.setPassword(null);String userJsonStr = JSON.toJSONString(user);UserDetails userDetails = User.withUsername(userJsonStr).password(password).authorities(authorities).build();return userDetails;
}
到这里,UserDetailsImpl
的完整代码如下:
@Service
@Slf4j
public class UserDetailsImpl implements UserDetailsService {@AutowiredXcUserMapper xcUserMapper;@AutowiredXcMenuMapper xcMenuMapper;@AutowiredApplicationContext applicationContext;/*** @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("认证请求数据格式不对");}// 获取认证类型,beanName就是 认证类型 + 后缀,例如 password + _authservice = password_authserviceString authType = authParamsDto.getAuthType();// 根据认证类型,从Spring容器中取出对应的beanAuthService authService = applicationContext.getBean(authType + "_authservice", AuthService.class);XcUserExt user = authService.execute(authParamsDto);return getUserPrincipal(user);}public UserDetails getUserPrincipal(XcUserExt user) {// 获取用户idString userId = user.getId();// 根据用户id查询用户权限List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(userId);ArrayList<String> permissions = new ArrayList<>();// 没权限,给一个默认的if (xcMenus.isEmpty()) {permissions.add("test");} else {// 获取权限,加入到集合里xcMenus.forEach(xcMenu -> {permissions.add(xcMenu.getCode());});}// 设置权限user.setPermissions(permissions);String[] authorities = permissions.toArray(new String[0]);String password = user.getPassword();user.setPassword(null);String userJsonStr = JSON.toJSONString(user);UserDetails userDetails = User.withUsername(userJsonStr).password(password).authorities(authorities).build();return userDetails;}
}
6. 实现发送邮件
1. 在checkcode模块中导入邮件发送相关依赖
<!-- https://mvnrepository.com/artifact/javax.activation/activation -->
<dependency><groupId>javax.activation</groupId><artifactId>activation</artifactId><version>1.1.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.mail/mail -->
<dependency><groupId>javax.mail</groupId><artifactId>mail</artifactId><version>1.4.7</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
<dependency><groupId>org.apache.commons</groupId><artifactId>commons-email</artifactId><version>1.4</version>
</dependency>
- 编写邮件工具类
public class MailUtil {public static void main(String[] args) throws MessagingException {//可以在这里直接测试方法,填自己的邮箱即可sendTestMail("1272633570@qq.com", new MailUtil().achieveCode());}/*** 发送邮件* @param email 收件邮箱号* @param code 验证码* @throws MessagingException*/public static void sendTestMail(String email, String code) throws MessagingException {// 创建Properties 类用于记录邮箱的一些属性Properties props = new Properties();// 表示SMTP发送邮件,必须进行身份验证props.put("mail.smtp.auth", "true");//此处填写SMTP服务器props.put("mail.smtp.host", "smtp.qq.com");//端口号,QQ邮箱端口587props.put("mail.smtp.port", "587");// 此处填写,写信人的账号props.put("mail.user", "1272633570@qq.com");// 此处填写16位STMP口令props.put("mail.password", "bqmqocdniybljfbb");// 构建授权信息,用于进行SMTP进行身份验证Authenticator authenticator = new Authenticator() {protected PasswordAuthentication getPasswordAuthentication() {// 用户名、密码String userName = props.getProperty("mail.user");String password = props.getProperty("mail.password");return new PasswordAuthentication(userName, password);}};// 使用环境属性和授权信息,创建邮件会话Session mailSession = Session.getInstance(props, authenticator);// 创建邮件消息MimeMessage message = new MimeMessage(mailSession);// 设置发件人InternetAddress form = new InternetAddress(props.getProperty("mail.user"));message.setFrom(form);// 设置收件人的邮箱InternetAddress to = new InternetAddress(email);message.setRecipient(RecipientType.TO, to);// 设置邮件标题message.setSubject("ZL邮件测试");// 设置邮件的内容体message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");// 最后当然就是发送邮件啦Transport.send(message);}/*** 生成验证码* @return*/public static String achieveCode() { //由于数字 1 、 0 和字母 O 、l 有时分不清楚,所以,没有数字 1 、 0String[] beforeShuffle = new String[]{"2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F","G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a","b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v","w", "x", "y", "z"};List<String> list = Arrays.asList(beforeShuffle);//将数组转换为集合Collections.shuffle(list); //打乱集合顺序StringBuilder sb = new StringBuilder();for (String s : list) {sb.append(s); //将集合转化为字符串}return sb.substring(3, 8);}
- 测试成功了,可以发送邮件了
7. 实现找回密码
邮箱验证码:/api/checkcode/phone?param1=电子邮箱地址
找回密码:/api/auth/findpassword
执行流程
- 校验验证码,不一致则抛异常
- 判断两次密码是否一致,不一致则抛异常
- 根据邮箱查询用户
- 如果找到用户,更新其密码
下面是具体步骤:
- 在Controller层中添加对应的接口
@ApiOperation(value = "发送邮箱验证码", tags = "发送邮箱验证码")
@PostMapping("/phone")
public void sendEMail(@RequestParam("param1") String email) {}
- 定义
FindPswDto
类,用于接收找回密码的参数信息
@Data
@NoArgsConstructor
@AllArgsConstructor
public class FindPswDto {String cellphone;String email;String checkcodekey;String checkcode;String password;String confirmpwd;
}
注意: 找回密码在auth模块下
- service层需要修改
service层
和添加VerifyService
public interface SendCodeService {/*** 向目标邮箱发送验证码* @param email 目标邮箱* @param code 我们发送的验证码*/void sendEMail(String email, String code);}
public interface VerifyService {void findPassword(FindPswDto findPswDto);
}
实现service
SendCodeServiceImpl
:
@Service
@Slf4j
public class SendCodeServiceImpl implements SendCodeService {public final Long CODE_TTL = 120L;@AutowiredStringRedisTemplate redisTemplate;@Overridepublic void sendEMail(String email, String code) {// 1. 向用户发送验证码try {MailUtil.sendTestMail(email, code);} catch (MessagingException e) {log.debug("邮件发送失败:{}", e.getMessage());XueChengPlusException.cast("发送验证码失败,请稍后再试");}// 2. 将验证码缓存到redis,TTL设置为2分钟redisTemplate.opsForValue().set(email, code, CODE_TTL, TimeUnit.SECONDS);}
}
注意要事先在auth工程中导入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>
VerifyServiceImpl
@Service
public class VerifyServiceImpl implements VerifyService {@AutowiredStringRedisTemplate redisTemplate;@Overridepublic void findPassword(FindPswDto findPswDto) {String email = findPswDto.getEmail();String checkcode = findPswDto.getCheckcode();Boolean verify = verify(email, checkcode);if (!verify) {throw new RuntimeException("验证码输入错误");}String password = findPswDto.getPassword();String confirmpwd = findPswDto.getConfirmpwd();if (!password.equals(confirmpwd)) {throw new RuntimeException("两次输入的密码不一致");}LambdaQueryWrapper<XcUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(XcUser::getEmail, findPswDto.getEmail());XcUser user = userMapper.selectOne(lambdaQueryWrapper);if (user == null) {throw new RuntimeException("用户不存在");}user.setPassword(new BCryptPasswordEncoder().encode(password));userMapper.updateById(user);}
}
完善Controller层
在
LoginController
中添加sendEMail()
@Autowired SendCodeService sendCodeService;@ApiOperation(value = "发送邮箱验证码", tags = "发送邮箱验证码") @PostMapping("/phone") public void sendEMail(@RequestParam("param1") String email) {String code = MailUtil.achieveCode();sendCodeService.sendEMail(email, code); }
在CheckCodeController
中添加findPassword()
@ApiOperation(value = "找回密码", tags = "找回密码")
@PostMapping("/findpassword")
public void findPassword(@RequestBody FindPswDto findPswDto) {verifyService.findPassword(findPswDto);
}
- 测试成功
8. 实现用户注册
接口
- 邮箱验证码:/api/checkcode/phone?param1=邮箱
- 注册:/api/auth/register
请求:
{"cellphone":'',
"username":'',
"email":'',
"nickname":'',
"password":'',
"confirmpwd":'',
"checkcodekey":'',
"checkcode":''
}
- 执行流程
- 校验验证码,不一致,抛异常
- 校验两次密码是否一致,不一致,抛异常
- 校验用户是否存在,已存在,抛异常
- 向用户表、用户关系角色表添加数据,角色为学生
下面是具体步骤:
- 准备一个Dto类,接收注册请求的参数
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterDto {private String cellphone;private String checkcode;private String checkcodekey;private String confirmpwd;private String email;private String nickname;private String password;private String username;}
- 在
LoginController
中添加接口
@ApiOperation(value = "注册", tags = "注册")
@PostMapping("/register")
public void register(@RequestBody RegisterDto registerDto) {}
- Service层
void register(RegisterDto registerDto);
- 实现Service
@Override
@Transactional
public void register(RegisterDto registerDto) {String uuid = UUID.randomUUID().toString();String email = registerDto.getEmail();String checkcode = registerDto.getCheckcode();Boolean verify = verify(email, checkcode);if (!verify) {throw new RuntimeException("验证码输入错误");}String password = registerDto.getPassword();String confirmpwd = registerDto.getConfirmpwd();if (!password.equals(confirmpwd)) {throw new RuntimeException("两次输入的密码不一致");}LambdaQueryWrapper<XcUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();lambdaQueryWrapper.eq(XcUser::getEmail, registerDto.getEmail());XcUser user = userMapper.selectOne(lambdaQueryWrapper);if (user != null) {throw new RuntimeException("用户已存在,一个邮箱只能注册一个账号");}XcUser xcUser = new XcUser();BeanUtils.copyProperties(registerDto, xcUser);xcUser.setPassword(new BCryptPasswordEncoder().encode(password));xcUser.setId(uuid);xcUser.setUtype("101001"); // 学生类型xcUser.setStatus("1");xcUser.setName(registerDto.getNickname());xcUser.setCreateTime(LocalDateTime.now());int insert = userMapper.insert(xcUser);if (insert <= 0) {throw new RuntimeException("新增用户信息失败");}XcUserRole xcUserRole = new XcUserRole();xcUserRole.setId(uuid);xcUserRole.setUserId(uuid);xcUserRole.setRoleId("17");xcUserRole.setCreateTime(LocalDateTime.now());int insert1 = xcUserRoleMapper.insert(xcUserRole);if (insert1 <= 0) {throw new RuntimeException("新增用户角色信息失败");}
}
- 完善Controller
@ApiOperation(value = "注册", tags = "注册")
@PostMapping("/register")
public void register(@RequestBody RegisterDto registerDto) {verifyService.register(registerDto);
}
- 完成用户注册
9. Redis实现验证码业务
在认证时一般都需要输入验证码,验证码有什么用?
验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。
为了保护系统的安全在一些比较重要的操作都需要验证码。
验证码的类型也有很多:图片、语音、手机短信验证码等。
下面导入黑马的验证码模块:
- 定义nacos配置文件
server:servlet:context-path: /checkcodeport: 63075
- 配置
redis-dev.yaml
spring: redis:host: 192.168.101.65port: 6379password: redisdatabase: 0lettuce:pool:max-active: 20max-idle: 10min-idle: 0timeout: 10000#redisson:#配置文件目录#config: classpath:singleServerConfig.yaml
- 新增网关的nacos配置
- id: auth-serviceuri: lb://auth-servicepredicates:- Path=/auth/**
- id: checkcodeuri: lb://checkcodepredicates:- Path=/checkcode/**
- 验证码模块的bootstrap.yaml
spring:application:name: checkcodecloud:nacos:server-addr: 192.168.101.65:8848discovery:namespace: ${spring.profiles.active}group: xuecheng-plus-projectconfig:namespace: ${spring.profiles.active}group: xuecheng-plus-projectfile-extension: yamlrefresh-enabled: trueshared-configs:- data-id: swagger-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: true- data-id: logging-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: true- data-id: redis-${spring.profiles.active}.yamlgroup: xuecheng-plus-commonrefresh: trueprofiles:active: dev
- 在验证码模块中引入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>
Kaptcha图片验证码配置类
@Configuration public class KaptchaConfig {//图片验证码生成器,使用开源的kaptcha@Beanpublic DefaultKaptcha producer() {Properties properties = new Properties();properties.put("kaptcha.border", "no");properties.put("kaptcha.textproducer.font.color", "black");properties.put("kaptcha.textproducer.char.space", "10");properties.put("kaptcha.textproducer.char.length","4");properties.put("kaptcha.image.height","34");properties.put("kaptcha.image.width","138");properties.put("kaptcha.textproducer.font.size","25");properties.put("kaptcha.noise.impl","com.google.code.kaptcha.impl.NoNoise");Config config = new Config(properties);DefaultKaptcha defaultKaptcha = new DefaultKaptcha();defaultKaptcha.setConfig(config);return defaultKaptcha;} }
创建一个DTO来保存验证码信息
@Data public class CheckCodeResultDto {/*** key用于验证*/private String key;/*** 混淆后的内容* 举例:* 1.图片验证码为:图片base64编码* 2.短信验证码为:null* 3.邮件验证码为: null* 4.邮件链接点击验证为:null* ...*/private String aliasing; }
创建一个验证码接口
@Slf4j public abstract class AbstractCheckCodeService implements CheckCodeService {protected CheckCodeGenerator checkCodeGenerator;protected KeyGenerator keyGenerator;protected CheckCodeStore checkCodeStore;public abstract void setCheckCodeGenerator(CheckCodeGenerator checkCodeGenerator);public abstract void setKeyGenerator(KeyGenerator keyGenerator);public abstract void setCheckCodeStore(CheckCodeStore CheckCodeStore);/*** @description 生成验证公用方法* @param checkCodeParamsDto 生成验证码参数* @param code_length 验证码长度* @param keyPrefix key的前缀* @param expire 过期时间* @return com.xuecheng.checkcode.service.AbstractCheckCodeService.GenerateResult 生成结果* @author Mr.M* @date 2022/9/30 6:07*/public GenerateResult generate(CheckCodeParamsDto checkCodeParamsDto,Integer code_length,String keyPrefix,Integer expire){//生成四位验证码String code = checkCodeGenerator.generate(code_length);log.debug("生成验证码:{}",code);//生成一个keyString key = keyGenerator.generate(keyPrefix);//存储验证码checkCodeStore.set(key,code,expire);//返回验证码生成结果GenerateResult generateResult = new GenerateResult();generateResult.setKey(key);generateResult.setCode(code);return generateResult;}@Dataprotected class GenerateResult{String key;String code;}public abstract CheckCodeResultDto generate(CheckCodeParamsDto checkCodeParamsDto);public boolean verify(String key, String code){if (StringUtils.isBlank(key) || StringUtils.isBlank(code)){return false;}String code_l = checkCodeStore.get(key);if (code_l == null){return false;}boolean result = code_l.equalsIgnoreCase(code);if(result){//删除验证码checkCodeStore.remove(key);}return result;}}
创建一个数字生成器
@Component("NumberLetterCheckCodeGenerator") public class NumberLetterCheckCodeGenerator implements CheckCodeService.CheckCodeGenerator {@Overridepublic String generate(int length) {String str="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";Random random=new Random();StringBuffer sb=new StringBuffer();for(int i=0;i<length;i++){int number=random.nextInt(36);sb.append(str.charAt(number));}return sb.toString();}}
创建一个图片验证码生成器
@Service("PicCheckCodeService") public class PicCheckCodeServiceImpl extends AbstractCheckCodeService implements CheckCodeService {@Autowiredprivate DefaultKaptcha kaptcha;@Resource(name="NumberLetterCheckCodeGenerator")@Overridepublic void setCheckCodeGenerator(CheckCodeGenerator checkCodeGenerator) {this.checkCodeGenerator = checkCodeGenerator;}@Resource(name="UUIDKeyGenerator")@Overridepublic void setKeyGenerator(KeyGenerator keyGenerator) {this.keyGenerator = keyGenerator;}@Resource(name="MemoryCheckCodeStore")@Overridepublic void setCheckCodeStore(CheckCodeStore checkCodeStore) {this.checkCodeStore = checkCodeStore;}@Overridepublic CheckCodeResultDto generate(CheckCodeParamsDto checkCodeParamsDto) {GenerateResult generate = generate(checkCodeParamsDto, 4, "checkcode:", 60);String key = generate.getKey();String code = generate.getCode();String pic = createPic(code);CheckCodeResultDto checkCodeResultDto = new CheckCodeResultDto();checkCodeResultDto.setAliasing(pic);checkCodeResultDto.setKey(key);return checkCodeResultDto;}private String createPic(String code) {// 生成图片验证码ByteArrayOutputStream outputStream = null;BufferedImage image = kaptcha.createImage(code);outputStream = new ByteArrayOutputStream();String imgBase64Encoder = null;try {// 对字节数组Base64编码//BASE64Encoder base64Encoder = new BASE64Encoder();ImageIO.write(image, "png", outputStream);imgBase64Encoder = "data:image/png;base64," + EncryptUtil.encodeBase64(outputStream.toByteArray());} catch (IOException e) {e.printStackTrace();} finally {try {outputStream.close();} catch (IOException e) {e.printStackTrace();}}return imgBase64Encoder;} }
在验证码模块中(xuecheng-plus-checkcod),
CheckCodeService
是验证码接口,其内部还有一个CheckCodeStore
接口,CheckCodeStore
接口是负责存储验证码的
public interface CheckCodeService {/*** @param checkCodeParamsDto 生成验证码参数* @return com.xuecheng.checkcode.model.CheckCodeResultDto 验证码结果* @description 生成验证码* @author Mr.M* @date 2022/9/29 18:21*/CheckCodeResultDto generate(CheckCodeParamsDto checkCodeParamsDto);/*** @param key* @param code* @return boolean* @description 校验验证码* @author Mr.M* @date 2022/9/29 18:46*/public boolean verify(String key, String code);/*** @author Mr.M* @description 验证码生成器* @date 2022/9/29 16:34*/public interface CheckCodeGenerator {/*** 验证码生成** @return 验证码*/String generate(int length);}/*** @author Mr.M* @description key生成器* @date 2022/9/29 16:34*/public interface KeyGenerator {/*** key生成** @return 验证码*/String generate(String prefix);}/*** @author Mr.M* @description 验证码存储* @date 2022/9/29 16:34*/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中
generatePicCheckCode
方法,是用来生成验证码图片的
@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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"
}
- Controller中
verify
方法,是用来校验验证码的
@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
此时,Controller完整代码如下:
@Slf4j
@Api(value = "验证码服务接口")
@RestController
public class CheckCodeController {@AutowiredSendCodeService sendCodeService;@Resource(name = "PicCheckCodeService")private CheckCodeService picCheckCodeService;@ApiOperation(value = "生成验证信息", notes = "生成验证信息")@PostMapping(value = "/pic")public CheckCodeResultDto generatePicCheckCode(CheckCodeParamsDto checkCodeParamsDto) {return picCheckCodeService.generate(checkCodeParamsDto);}@ApiOperation(value = "校验", notes = "校验")@ApiImplicitParams({@ApiImplicitParam(name = "name", value = "业务名称", required = true, dataType = "String", paramType = "query"),@ApiImplicitParam(name = "key", value = "验证key", required = true, dataType = "String", paramType = "query"),@ApiImplicitParam(name = "code", value = "验证码", required = true, dataType = "String", paramType = "query")})@PostMapping(value = "/verify")public Boolean verify(String key, String code) {Boolean isSuccess = picCheckCodeService.verify(key, code);return isSuccess;}
}
最后附上自己码云黑马学成在线-Plus项目地址
注:当时参照大佬 Kele 的项目以及文章
SpringSecurity OAuth2实现单点登录,微信扫码登录,Redis缓存验证码---入门到实战相关推荐
- 微信扫码登录超详细教程
一.OAuth2 1.OAuth2介绍 2.OAuth2解决什么问题 (1)OAuth2提出的背景 照片拥有者想要在云冲印服务上打印照片,云冲印服务需要访问云存储服务上的资源.(2)图例 资源 ...
- 微信开放平台开发——网页微信扫码登录(OAuth2.0)
1.OAuth2.0 OAuth(开放授权)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用. 允许用户提供 ...
- 微信官方你真的懂OAuth2?Spring Security OAuth2整合企业微信扫码登录
❝ 企业微信扫码登录DEMO参见文末. 现在很多企业都接入了企业微信,作为私域社群工具,企业微信开放了很多API,可以打通很多自有的应用.既然是应用,那肯定需要做登录.正好企业微信提供了企业微信扫码授 ...
- 通过微信扫码登录剖析 oauth2 认证授权技术
本文目录 前言 趣味解读oauth2 oauth2精髓 oauth2核心概念 结合微信登录深刻理解oauht2 本文小结 前言 相信很多小伙伴在学习 JAVA 的过程中或多或少接触或者开发过类似于 x ...
- Spring Boot + OAuth2.0 实现微信扫码登录,这才叫优雅
点击"终码一生",关注,置顶公众号 每日技术干货,第一时间送达! 微信开放平台:微信扫码登录功能 官方文档:https://developers.weixin.qq.com/doc ...
- Spring Boot + OAuth2.0 实现微信扫码登录,这才叫优雅!!
微信开放平台:微信扫码登录功能 官方文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_ ...
- Java实现微信扫码登录并实现认证授权
Java实现微信扫码登录并实现认证授权 1.登录流程及原理 1.1 OAuth2协议 网站应用微信登录是基于OAuth2.0协议标准构建的微信OAuth2.0授权登录系统. 在进行微信OAuth2.0 ...
- 前后端分离项目知识汇总(微信扫码登录,手机验证码登录,JWT)
整合篇二 前言 整合JWT 用户登录业务介绍 单一服务器模式 SSO(single sign on)模式 SSO登录三种常见的方式 传统用户身份验证 解决方案 JWT令牌 JWT的原则 整合 整合腾讯 ...
- Vue+abp微信扫码登录
最近系统中要使用微信扫码登录,根据微信官方文档和网络搜索相关文献实现了.分享给需要的人,也作为自己的一个笔记.后端系统是基于ABP的,所以部分代码直接使用了abp的接口,直接拷贝代码编译不通过. 注册 ...
最新文章
- Science给博士新生的“欢迎”信:5点期望太扎心!
- autoencoder
- linux deepin手动升级内核命令
- Web Service概念梳理
- 一个数独问题的算法(已更新,提供一个简单算法,欢迎拍砖)
- 为什么用JS取不到cookie的值?解决方法如下!
- JSONObject遍历
- linux基础知识考试试题
- 升降压型电感电容计算
- 如何在Windows 10上不用鼠标使用文件资源管理器
- C++从键盘输入的方法
- Anroid中Service详解
- 海思SD3403开发/SS928V100 移植Yolo模型
- Java数据结构——直接插入排序+希尔排序+冒泡排序
- 智能温度计APP一键开发(快速搭建)
- 关于SSD HMB与CMB
- nginx 自动屏蔽攻击者ip
- 全国大学生计算机设计大赛 南京作品展示,我校学子10件作品在2019中国大学生计算机设计大赛省赛中获奖...
- Java小农养成记第四十天
- html清除浮动的那一块区域,CSS中的BFC,外边距折叠,清除浮动
热门文章
- html组织结构图 源码,css绘制组织结构图
- nrf2401数据传输原理
- 看不到下雪,就用Python制造雪景图
- 用计算机写词,全民一起来学习 用遗传算法让电脑写宋词
- 利用caffe识别水杯
- 劳务员培训建筑八大员培训劳务员建筑劳务分包经营存在的问题
- 对自己职业迷茫的人一定要看这篇文章。你的格局有多大,舞台就有多大。
- updatepanel失效怎么办_UpdatePanel DataGrid 中按钮失效(只刷新本页)的解决办法...
- mysql 子表_MySQL子查询,派生表和通用表达式
- Gradle详解(Groovy)