例如课程发布后用户通过在线学习页面点播视频进行学习。如何去记录学生的学习过程呢?要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程,如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。
认证授权模块实现平台所有用户的身份认证与用户授权功能。
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证的表现形式有:用户名密码登录,微信扫码等方式。
本案例基于黑马程序员2023版的学成在线-Plus中的第五章认证授权v3.1。
项目包括学生、学习机构的老师、平台运营人员三类用户,不管哪一类用户在访问项目受保护资源时都需要进行身份认证。比如:发布课程操作,需要学习机构的老师首先登录系统成功,然后再执行发布课程操作。创建订单,需要学生用户首先登录系统,才可以创建订单。如下图:
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。比如:用户去发布课程,系统首先进行用户身份认证,认证通过后继续判断用户是否有发布课程的权限,如果没有权限则拒绝继续访问系统,如果有权限则继续发布课程。如下图:
用户只需要认证一次便可以在多个拥有访问权限的系统中访问,这个功能叫做单点登录。
引用百度百科:单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
如下图,用户只需要认证一次,便可以在多个拥有访问权限的系统中访问。
为了提高用户体验,很多网站有扫码登录的功能,如:微信扫码登录、QQ扫码登录等。扫码登录的好处是用户不用输入账号和密码,操作简便,另外一个好处就是有利于用户信息的共享,互联网的优势就是资源共享,用户也是一种资源,对于一个新网站如果让用户去注册是很困难的,如果提供了微信扫码登录将省去用户注册的成本,是一种非常有效的推广手段。
微信扫码登录其中的原理正是使用了第三方认证,如下图:
认证功能几乎是每个项目都要具备的功能,并且它与业务无关,市面上有很多认证框架,如: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
下边我们使用Spring Security框架快速构建认证授权功能体系。
1. 创建一个命名为 auth普通的spring boot工程,可以连接数据库。此工程不具备认证授权的功能。
2. 创建一个Controller来测试
@Slf4j
@RestController
public class LoginController {
@Autowired
XcUserMapper 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资源";
}
}
以上测试一切正常说明此工程部署成功。
1. 向 auth 认证工程集成Spring security,向pom.xml加入Spring Security所需要的依赖。
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
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的权限是p2
manager.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,如果输入的密码不正确会认证失败,输入正确显示登录成功。
完整代码如下:
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public UserDetailsService userDetailsService() {
// 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
// 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2
manager.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();
}
@Override
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
。 用户认证通过去访问系统资源时spring security进行授权控制,判断用户是否有该资源的访问权限,如果有则继续访问,如果没有则拒绝访问。下边测试授权功能:
在WebSecurityConfig类配置zhangsan拥有p1权限,lisi拥有p2权限。在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;
}
指定资源与权限的关系
什么是系统的资源?
比如:查询一个用户的信息,用户信息就是系统的资源,要访问资源需要通过==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==,此方法没有授权控制。
整理授权的过程见下图所示:
注意:在controller中使用*@PreAuthorize(“hasAuthority(’ ')”)*配置权限
通过测试认证和授权两个功能,我们了解了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。
前面我们提到的微信扫码认证,是一种第三方认证方式,这种认证方式是基于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
表示:资源服务器校验令牌通过后,提供受保护的资源同意登录
) 要想测试授权码模式,首先要配置授权服务,即上图中的认证服务器,需要配置授权服务及认证服务(令牌策略)
在config中创建AuthorizationService.java (授权服务器)和 TokenConfig.java(令牌策略配置)
AuthorizationService
使用@EnableAuthorizationServer
注解标识并继承AuthorizationServerConfigurerAdapter
来配置OAuth2.0
授权服务器
@Configuration
@EnableAuthorizationServer
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
···
}
AuthorizationServerConfigurerAdapter
要求配置以下几个类
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 {
@Autowired
TokenStore tokenStore;
@Bean
public 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 {
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
···
}
到这里WebSecurityConfig里面已经配置了密码方式,OAuth2,安全拦截机制,其中完整代码如下:
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// OAuth2
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
// 密码设置为明文模式
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
//配置安全拦截机制
@Override
protected 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,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。
密码模式相对授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如下图:
资源拥有者提供账号和密码
客户端向认证服务申请令牌,请求中携带账号和密码
认证服务校验账号和密码正确颁发令牌。
开始测试:
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是我们自己开发的情况下。
JSON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任,它可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止内容篡改。官网:https://jwt.io/
使用JWT可以实现无状态认证,什么是无状态认证?
传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。
如下图,当用户访问应用服务,每个应用服务都会去服务器查看session信息,如果session中没有该用户则说明用户没有登录,此时就会重新认证,而解决这个问题的方法是Session复制、Session黏贴。
如果是基于令牌技术在分布式系统中实现认证则服务端不用存储session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从jwt解析出用户信息。这个过程就是无状态认证。
缺点
JWT令牌较长,占存储空间比较大,下面是一个JWT令牌的示例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQyNTQ2NzIsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijg4OTEyYjJkLTVkMDUtNGMxNC1iYmMzLWZkZTk5NzdmZWJjNiIsImNsaWVudF9pZCI6ImMxIn0.wkDBL7roLrvdBG2oGnXeoXq-zZRgE9IVV2nxd-ez_oA
JWT令牌由三部分组成,每部分中间使用点(.)分隔,例如xxxx.yyyyyy.zzzzzzz
Header:第一部分是头部
{
"alg": "HS256",
"typ": "JWT"
}
Payload:第二部分是负载,内容也是一个Json对象
{
"sub": "1234567890",
"name": "456",
"admin": true
}
Sugbature:第三部分是签名,此部分用于防止JWT内容被篡改。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
在认证服务(TokenConfig)中配置JWT令牌服务,即可实现生成JWT格式的令牌
@Configuration
public class TokenConfig {
private String SIGNING_KEY = "mq123";
@Autowired
TokenStore tokenStore;
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
// // 内存存储令牌
// @Bean
// public TokenStore tokenStore() {
// //使用内存存储令牌(普通令牌)
// return new InMemoryTokenStore();
// }
// JWT令牌
@Bean
public 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"
}
拿到了jwt令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务,比如:内容管理服务,客户端申请到jwt令牌,携带jwt去内容管理服务查询课程信息,此时内容管理服务要对jwt进行校验,只有jwt合法才可以继续访问。如下图:
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
dependency>
@Configuration
public class TokenConfig {
private String SIGNING_KEY = "mq123";
// JWT令牌
@Bean
public JwtAccessTokenConverter accessTokenConverter(){
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY);
return converter;
}
// 令牌选择
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
}
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
public static final String RESOURCE_ID = "xuecheng-plus";
@Autowired
TokenStore tokenStore;
// 配置资源服务访问令牌方式
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID)
.tokenStore(tokenStore)
.stateless(true);
}
// 安全http配置
@Override
public 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
GET {{content_host}}/content/course/160
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQzOTMwOSwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiNTAxNDNiZTItOGM3ZC00MmUzLWEwNDMtMTQwMGQ5NWQ5MmZiIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.o3nWLeRkJncEnnZ0egFmBpyC8Keq-L8IY6k0Uc0a96c
{
"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": "计算机视觉"
}
{
"error": "invalid_token",
"error_description": "Cannot convert access token to JSON"
}
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
到目前为止,测试通过了认证服务颁发的JWT令牌,客户端携带JWT访问资源服务,资源服务会对JWT的合法性进行验证,如下图
仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示:
所有访问微服务的请求都要经过网关,在网关进行用户身份的认证可以将很多非法的请求拦截到微服务以外,这叫做网关认证。
下边需要明确网关的职责:
网站白名单维护 : 针对不用认证的URL全部放行。
校验jwt的合法性 : 除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。
网关负责授权吗? 网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口。
下边实现网关认证,实现以下职责:
网站白名单维护
校验jwt的合法性。
下面是详细步骤:
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-oauth2artifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>fastjsonartifactId>
dependency>
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();
}
}
@Autowired
private TokenStore tokenStore;
@Override
public 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);
}
//判断是否是有效的token
OAuth2AccessToken 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));
}
@Override
public 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 {
//安全拦截配置
@Bean
public SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {
return http.authorizeExchange()
.pathMatchers("/**").permitAll()
.anyExchange().authenticated()
.and().csrf().disable().build();
}
}
TokenConfig
@Configuration
public class TokenConfig {
String SIGNING_KEY = "mq123";
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SIGNING_KEY);
return converter;
}
}
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
{
"errMessage": "认证令牌无效"
}
注意:网关鉴权功能调试通过后,由于目前还没有开发认证功能,前端请求网关的URL不在白名单时,会出现没有认证
错误,所以暂时在白名单中添加全部放行配置,待认证功能开发完成后,再屏蔽全部放行配置
/**=暂时全部放开
/auth/**=认证地址
/content/open/**=内容管理公开访问接口
/media/open/**=媒资管理公开访问接口
由于是在网关处进行令牌校验,所以在微服务处不再校验令牌的合法性,修改内容管理服务content-api
的ResouceServerConfig
类,屏蔽authenticated()
。
@Override
public 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";
@Autowired
TokenStore tokenStore;
// 配置资源服务访问令牌方式
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId(RESOURCE_ID)
.tokenStore(tokenStore)
.stateless(true);
}
// 安全http配置
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // 禁用 CSRF 保护
.authorizeRequests() //配置对请求的授权策略
//.antMatchers("/r/**", "/course/**").authenticated() // 指定 "/r/" 和 "/course/" 这两个路径需要进行身份认证才能访问。
.anyRequest().permitAll(); // 允许所有其他请求(除了上面指定的路径之外)都可以被访问,不需要进行身份认证。
}
}
至此我们了解了使用Spring Security进行认证授权的过程,本节实现用户认证功能。
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。
本项目也要支持多种认证试。
基于的认证流程在研究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;
// }
UserDetailsService
,这里的mapper都用Mybatis-Plus生成@Service
public class UserDetailsImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
/**
*
* @param s 用户输入的登录账号
* @return UserDetails
* @throws UsernameNotFoundException
*/
@Override
public 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
格式与数据库中的密码进行比对。
如下:
@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
修改数据库中的密码为Bcrypt
格式,并且记录明文密码,稍后申请令牌时需要。
由于修改密码编码方式还需要将(授权服务的AuthorizationServer
)客户端的密钥更改为Bcrypt
格式。
@Override
public 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")
;
}
现在重启认证服务。
### 密码模式
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"
}
用户表中存储了用户的账号
、手机号
、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为用户的身份信息,这里有两个思路
相较而言,方案2比较简单,而且也不用破坏UserDetails的结构,这里采用方案二
下面是具体步骤:
@Service
public class UserDetailsImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
/**
*
* @param s 用户输入的登录账号
* @return UserDetails
* @throws UsernameNotFoundException
*/
@Override
public 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格式的内容,转换为用户对象去使用
下边编写一个工具类在各个微服务中去使用,获取当前登录用户的对象。
在``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;
}
@Data
public 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);
}
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。基于当前研究的Spring Security认证流程如何支持多样化的认证方式呢?
1、支持账号和密码认证
采用OAuth2协议的密码模式即可实现。
2、支持手机号加验证码认证
用户认证提交的是手机号和验证码,并不是账号和密码。
3、微信扫码认证
基于OAuth2协议与微信交互,学成在线网站向微信服务器申请到一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过。
目前我们测试通过OAuth2的密码模式,用户认证会提交账号和密码,由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。
在前边我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息。
而不同的认证方式提交的数据不一样,比如:手机加验证码方式会提交手机号和验证码,账号密码方式会提交账号、密码、验证码。
我们可以在loadUserByUsername()
方法上作文章,将用户原来提交的账号数据改为提交json数据,json数据可以扩展不同认证方式所提交的各种参数。
@Data
public class AuthParamsDto {
private String username; //用户名
private String password; //域 用于扩展
private String cellphone;//手机号
private String checkcode;//验证码
private String checkcodekey;//验证码key
private String authType; // 认证的类型 password:用户名密码模式类型 sms:短信模式类型
private Map<String, Object> payload = new HashMap<>();//附加数据,作为扩展,不同认证类型可拥有不同的附加数据。如认证类型为短信时包含smsKey : sms:3d21042d054548b08477142bbca95cfa; 所有情况下都包含clientId
}
loadUserByUsername()
方法@Service
public class UserDetailsImpl implements UserDetailsService {
@Autowired
XcUserMapper xcUserMapper;
/**
* @param s 用户输入的登录账号
* @return UserDetails
* @throws UsernameNotFoundException
*/
@Override
public 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,所以这里需要注入一个
@Autowired
public void setUserDetailsService(UserDetailsService userDetailsService){
super.setUserDetailsService(userDetailsService);
}
// 屏蔽密码对比,因为不是所有的认证方式都需要校验密码
@Override
protected 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);
}
### 密码模式
POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"Kyle","password":"111111"}
有了这些认证参数我们可以定义一个认证Service接口去进行各种方式的认证。
定义dto用户信息,为了扩展性让它继承XcUser(注:这里最好不要直接用XcUser类,理由在之前的文章也说过,万一我们需要扩展一些其他的用户信息,那么我们直接修改XcUser类是不现实的,因为XcUser类对应的是数据库中的表。所以即使我们要使用XcUser类作为返回类型,也最好是让一个其他的类继承XcUser)
@Data
public class XcUserExt extends XcUser {
}
/**
* 认证Service
*/
public interface AuthService {
/**
* 认证方法
* @param authParamsDto 认证参数
* @return 用户信息
*/
XcUserExt execute(AuthParamsDto authParamsDto);
}
定义AuthService接口的实现类,即各种认证方式
定义AuthService接口的实现类,即各种认证方式
一个接口的多种实现,我们依靠beanName
来做区分,例如这里的password_authservice
,见名知意就知道是密码登录方式
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
return null;
}
}
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService {
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
return null;
}
}
修改UserDetailsImpl
的loadUserByUsername()
@Override
public 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认证流程修改为如下
上节定义了AuthService认证接口,下边实现该接口实现账号密码认证
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
PasswordEncoder passwordEncoder;
@Override
public 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
的相关代码抽取为一个方法
@Override
public 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);
- // 返回空表示用户不存在,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;
+ }
重启认证服务,测试申请令牌接口
### 密码模式
POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"Kyle","password":"111111","authType":"password"}
在认证时一般都需要输入验证码,验证码有什么用?
验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。
为了保护系统的安全在一些比较重要的操作都需要验证码。
验证码的类型也有很多:图片、语音、手机短信验证码等。
下面导入黑马的验证码模块:
server:
servlet:
context-path: /checkcode
port: 63075
redis-dev.yaml
spring:
redis:
host: 192.168.101.65
port: 6379
password: redis
database: 0
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 0
timeout: 10000
#redisson:
#配置文件目录
#config: classpath:singleServerConfig.yaml
- id: auth-service
uri: lb://auth-service
predicates:
- Path=/auth/**
- id: checkcode
uri: lb://checkcode
predicates:
- Path=/checkcode/**
spring:
application:
name: checkcode
cloud:
nacos:
server-addr: 192.168.101.65:8848
discovery:
namespace: ${spring.profiles.active}
group: xuecheng-plus-project
config:
namespace: ${spring.profiles.active}
group: xuecheng-plus-project
file-extension: yaml
refresh-enabled: true
shared-configs:
- data-id: swagger-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
- data-id: logging-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
- data-id: redis-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
profiles:
active: dev
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
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
@Autowired
StringRedisTemplate redisTemplate;
@Override
public void set(String key, String value, Integer expire) {
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MINUTE);
}
@Override
public String get(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
@Override
public void remove(String key) {
redisTemplate.delete(key);
}
}
验证码服务测试:
generatePicCheckCode
方法,是用来生成验证码图片的@ApiOperation(value="生成验证信息", notes="生成验证信息")
@PostMapping(value = "/pic")
public CheckCodeResultDto generatePicCheckCode(CheckCodeParamsDto checkCodeParamsDto){
return picCheckCodeService.generate(checkCodeParamsDto);
}
### 获取验证码图片
POST localhost:53075/checkcode/pic
{
"key": "checkcode:20a2ccb511bc472ea785db14d0a547ba",
"aliasing": "data:image/png;base64,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"
}
verify
方法,是用来校验验证码的@PostMapping(value = "/verify")
public Boolean verify(String key, String code){
Boolean isSuccess = picCheckCodeService.verify(key,code);
return isSuccess;
}
### 校验验证码
POST localhost:53075/checkcode/verisfy?key=checkcode:c3dce1413f95414e943dcf0a97983fe8&code=HZCG
效果图:
![\[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(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)
执行流程如下:
@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> {
@Override
public CheckCodeClient create(Throwable throwable) {
return new CheckCodeClient() {
@Override
public Boolean verify(String key, String code) {
log.debug("调用验证码服务熔断异常:{}", throwable.getMessage());
return null;
}
};
}
}
@EnableFeignClients(basePackages = "com.xuecheng.*.feignclient")
@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
PasswordEncoder passwordEncoder;
+ @Autowired
+ CheckCodeClient checkCodeClient;
@Override
public 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 {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
CheckCodeClient checkCodeClient;
@Override
public 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;
}
}
首先测试验证码,分别输入正确的验证码和错误的验证码进行测试
输入正确的账号密码和错误的账号密码进行测试
登录成功将jwt令牌存储cookie.
测试自动登录
勾选自动登录cookie生成时间为30天,不勾选自动登录关闭浏览器窗口后自动删除cookie。
微信扫码登录基于OAuth2协议的授权码模式,
接口文档:
https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
流程如下:
第三方应用获取access_token令牌后即可请求微信获取用户的信息,成功获取到用户的信息表示用户在第三方应用认证成功。
步骤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: ""
});
根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程
WxLoginController
类,如下:@Slf4j
@Controller
public class WxLoginController {
@Autowired
WxAuthServiceImpl 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("wx_authservice")
public class WxAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper 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
*/
@Override
public 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;
}
}
接下来请求微信申请令牌。
使用restTemplate
请求微信,配置RestTemplate bean
在启动类配置restTemplate
@EnableFeignClients
@SpringBootApplication
public class AuthApplication {
public static void main(String[] args) {
SpringApplication.run(AuthApplication.class, args);
}
@Bean
RestTemplate restTemplate(){
RestTemplate restTemplate = new RestTemplate(new OkHttp3ClientHttpRequestFactory());
return restTemplate;
}
}
public interface WxAuthService {
public XcUser wxAuth(String code);
}
WxAuthServiceImpl
实现WxAuthService
和AuthService
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService, WxAuthService {
@Autowired
XcUserMapper xcUserMapper;
@Override
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
*/
@Override
public 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,code
String 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. 转为map
Map<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、手机扫码授权
向数据库保存用户信息,如果用户不存在,则将其保存在数据库
下面是具体步骤:
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 {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
XcUserRoleMapper xcUserRoleMapper;
@Autowired
WxAuthServiceImpl wxAuthService;
@Autowired
RestTemplate restTemplate;
@Value("${weixin.appid}")
String appid;
@Value("${weixin.secret}")
String secret;
@Override
public XcUser wxAuth(String code) {
// 1. 获取access_token
Map<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;
}
@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 {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
XcUserRoleMapper xcUserRoleMapper;
@Autowired
WxAuthServiceImpl wxAuthService;
@Autowired
RestTemplate restTemplate;
@Value("${weixin.appid}")
String appid;
@Value("${weixin.secret}")
String secret;
@Override
public XcUser wxAuth(String code) {
// 1. 获取access_token
Map<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 认证参数
*/
@Override
public 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,code
String 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. 转为map
Map<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和openid
String 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. 转为map
Map<String, String> map = JSON.parseObject(result, Map.class);
return map;
}
@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;
}
}
如何实现授权?业界通常基于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: 查询工资
}
优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理,也不需要修改授权代码,系统可扩展性强
在需要授权的接口处使用==@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,所以采取下面的解决方案
我们只判断拿到的异常信息是否为不允许访问
,如果是,则提示没有操作此功能的权限
@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());
}
这里我们可以重启服务测试
如何给用户分配权限呢?查看数据库中的表结构
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)删除用户权限
本项目是基于角色分配权限,如果要删除用户的权限可以给用户换角色,那么新角色下的权限就是用户的权限;如果不换用户的角色可以删除角色下的权限即删除角色权限关系表相应记录,这样操作是将角色下的权限删除,属于该角色的用户都将删除此权限。
使用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'
)
)
下面是具体步骤:
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
@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 {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
XcMenuMapper xcMenuMapper;
@Autowired
ApplicationContext applicationContext;
/**
* @param s 用户输入的登录账号
* @return UserDetails
* @throws UsernameNotFoundException
*/
@Override
public 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);
return getUserPrincipal(user);
}
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 = 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;
}
}
1. 在checkcode模块中导入邮件发送相关依赖
<dependency>
<groupId>javax.activationgroupId>
<artifactId>activationartifactId>
<version>1.1.1version>
dependency>
<dependency>
<groupId>javax.mailgroupId>
<artifactId>mailartifactId>
<version>1.4.7version>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-emailartifactId>
<version>1.4version>
dependency>
public class MailUtil {
public static void main(String[] args) throws MessagingException {
//可以在这里直接测试方法,填自己的邮箱即可
sendTestMail("[email protected]", 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邮箱端口587
props.put("mail.smtp.port", "587");
// 此处填写,写信人的账号
props.put("mail.user", "[email protected]");
// 此处填写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 、 0
String[] 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);
}
邮箱验证码:/api/checkcode/phone?param1=电子邮箱地址
找回密码:/api/auth/findpassword
执行流程
下面是具体步骤:
@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层
和添加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;
@Autowired
StringRedisTemplate redisTemplate;
@Override
public 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的依赖
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
VerifyServiceImpl
@Service
public class VerifyServiceImpl implements VerifyService {
@Autowired
StringRedisTemplate redisTemplate;
@Override
public 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);
}
接口
请求:
{
"cellphone":'',
"username":'',
"email":'',
"nickname":'',
"password":'',
"confirmpwd":'',
"checkcodekey":'',
"checkcode":''
}
下面是具体步骤:
@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) {
}
void register(RegisterDto registerDto);
@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("新增用户角色信息失败");
}
}
@ApiOperation(value = "注册", tags = "注册")
@PostMapping("/register")
public void register(@RequestBody RegisterDto registerDto) {
verifyService.register(registerDto);
}
在认证时一般都需要输入验证码,验证码有什么用?
验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。
为了保护系统的安全在一些比较重要的操作都需要验证码。
验证码的类型也有很多:图片、语音、手机短信验证码等。
下面导入黑马的验证码模块:
server:
servlet:
context-path: /checkcode
port: 63075
redis-dev.yaml
spring:
redis:
host: 192.168.101.65
port: 6379
password: redis
database: 0
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 0
timeout: 10000
#redisson:
#配置文件目录
#config: classpath:singleServerConfig.yaml
- id: auth-service
uri: lb://auth-service
predicates:
- Path=/auth/**
- id: checkcode
uri: lb://checkcode
predicates:
- Path=/checkcode/**
spring:
application:
name: checkcode
cloud:
nacos:
server-addr: 192.168.101.65:8848
discovery:
namespace: ${spring.profiles.active}
group: xuecheng-plus-project
config:
namespace: ${spring.profiles.active}
group: xuecheng-plus-project
file-extension: yaml
refresh-enabled: true
shared-configs:
- data-id: swagger-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
- data-id: logging-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
- data-id: redis-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
profiles:
active: dev
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.apache.commonsgroupId>
<artifactId>commons-pool2artifactId>
dependency>
Kaptcha图片验证码配置类
@Configuration
public class KaptchaConfig {
//图片验证码生成器,使用开源的kaptcha
@Bean
public 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);
//生成一个key
String key = keyGenerator.generate(keyPrefix);
//存储验证码
checkCodeStore.set(key,code,expire);
//返回验证码生成结果
GenerateResult generateResult = new GenerateResult();
generateResult.setKey(key);
generateResult.setCode(code);
return generateResult;
}
@Data
protected 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 {
@Override
public 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 {
@Autowired
private DefaultKaptcha kaptcha;
@Resource(name="NumberLetterCheckCodeGenerator")
@Override
public void setCheckCodeGenerator(CheckCodeGenerator checkCodeGenerator) {
this.checkCodeGenerator = checkCodeGenerator;
}
@Resource(name="UUIDKeyGenerator")
@Override
public void setKeyGenerator(KeyGenerator keyGenerator) {
this.keyGenerator = keyGenerator;
}
@Resource(name="MemoryCheckCodeStore")
@Override
public void setCheckCodeStore(CheckCodeStore checkCodeStore) {
this.checkCodeStore = checkCodeStore;
}
@Override
public 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
@Autowired
StringRedisTemplate redisTemplate;
@Override
public void set(String key, String value, Integer expire) {
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MINUTE);
}
@Override
public String get(String key) {
return (String) redisTemplate.opsForValue().get(key);
}
@Override
public void remove(String key) {
redisTemplate.delete(key);
}
}
验证码服务测试:
generatePicCheckCode
方法,是用来生成验证码图片的@ApiOperation(value="生成验证信息", notes="生成验证信息")
@PostMapping(value = "/pic")
public CheckCodeResultDto generatePicCheckCode(CheckCodeParamsDto checkCodeParamsDto){
return picCheckCodeService.generate(checkCodeParamsDto);
}
### 获取验证码图片
POST localhost:53075/checkcode/pic
{
"key": "checkcode:20a2ccb511bc472ea785db14d0a547ba",
"aliasing": "data:image/png;base64,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"
}
verify
方法,是用来校验验证码的@PostMapping(value = "/verify")
public Boolean verify(String key, String code){
Boolean isSuccess = picCheckCodeService.verify(key,code);
return isSuccess;
}
### 校验验证码
POST localhost:53075/checkcode/verisfy?key=checkcode:c3dce1413f95414e943dcf0a97983fe8&code=HZCG
此时,Controller完整代码如下:
@Slf4j
@Api(value = "验证码服务接口")
@RestController
public class CheckCodeController {
@Autowired
SendCodeService 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 的项目以及文章