学成在线-认证授权模块
1 模块需求分析
1.1 什么是认证授权
截至目前,项目已经完成了课程发布功能,课程发布后用户通过在线学习页面点播视频进行学习。如何去记录学生的学习过程呢?要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程,如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。
认证授权模块实现平台所有用户的身份认证与用户授权功能。
什么是用户身份认证?
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证的表现形式有:用户名密码登录,微信扫码等方式。
项目包括学生、学习机构的老师、平台运营人员三类用户,不管哪一类用户在访问项目受保护资源时都需要进行身份认证。比如:发布课程操作,需要学习机构的老师首先登录系统成功,然后再执行发布课程操作。创建订单,需要学生用户首先登录系统,才可以创建订单。如下图:

什么是用户授权?
用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。比如:用户去发布课程,系统首先进行用户身份认证,认证通过后继续判断用户是否有发布课程的权限,如果没有权限则拒绝继续访问系统,如果有权限则继续发布课程。

1.2 业务流程
1.2.1 统一认证
项目包括学生、学习机构的老师、平台运营人员三类用户,三类用户将使用统一的认证入口,如下图:

用户输入账号和密码提交认证,认证通过则继续操作。
项目由统一认证服务受理用户的认证请求,如下图:

认证通过由认证服务向给用户颁发令牌,相当于访问系统的通行证,用户拿着令牌去访问系统的资源。
1.2.2 单点登录
本项目基于微服务架构构建,微服务包括:内容管理服务、媒资管理服务、学习中心服务、系统管理服务等,为了提高用户体验性,用户只需要认证一次便可以在多个拥有访问权限的系统中访问,这个功能叫做单点登录。
引用百度百科:单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
如下图,用户只需要认证一次,便可以在多个拥有访问权限的系统中访问。

1.2.3 第三方认证
为了提高用户体验,很多网站有扫码登录的功能,如:微信扫码登录、QQ扫码登录等。扫码登录的好处是用户不用输入账号和密码,操作简便,另外一个好处就是有利于用户信息的共享,互联网的优势就是资源共享,用户也是一种资源,对于一个新网站如果让用户去注册是很困难的,如果提供了微信扫码登录将省去用户注册的成本,是一种非常有效的推广手段。
微信扫码登录其中的原理正是使用了第三方认证,如下图:

2 Spring Security
2.1 Spring Serurity 介绍
认证功能几乎是每个项目都要具备的功能,并且它与业务无关,市面上有很多认证框架,如: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、部署认证服务工程
从课程资料中拷贝xuecheng-plus-auth工程到自己的工程目录下。
此工程是一个普通的spring boot工程,可以连接数据库。
此工程不具备认证授权的功能。

2、创建数据库
创建xc_users数据库

在nacos中新增auth-service-dev.yaml:

初始工程自带了一个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资源";
}
}
启动工程,尝试访问http://localhost:63070/auth/r/r1 :,就是一个简单的springboot工程

2.2.2 认证测试
下边向auth认证工程集成Spring security,向pom.xml加入Spring Security所需要的依赖
重启工程,访问http://localhost:63070/auth/r/r1,会自动跳到登录页面

账号和密码是多少呢?下一步需要进行安全配置。
拷贝课程资料下的WebSecurityConfig.java到config下需要三部分内容:
1、用户信息
在内存配置两个用户:zhangsan、lisi
zhangsan用户拥有的权限为p1
lisi用户拥有的权限为p2
2、密码方式
暂时采用明文方式
3、安全拦截机制
/r/**开头的请求需要认证
登录成功到成功页面
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//配置用户信息服务
@Bean
@Override
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;
}
@Bean
public PasswordEncoder passwordEncoder() {
// //密码为明文方式
return NoOpPasswordEncoder.getInstance();
// return new BCryptPasswordEncoder();
}
//配置安全拦截机制
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
.anyRequest().permitAll()//其它请求全部放行
.and()
.formLogin().successForwardUrl("/login-success");//登录成功跳转到/login-success
}
}
重启工程
1、访问http://localhost:63070/auth/user/52 可以正常访问

2、访问http://localhost:63070/auth/r/r1 显示登录页面

账号zhangsan,密码为123,如果输入的密码不正确会认证失败,输入正确显示登录成功。
输入密码之后,再次访问,即可访问

为什么http://localhost:63070/auth/user/52 可以正常访问,访问http://localhost:63070/auth/r/r1 显示登录页面?
因为在安全拦截机制中配置了,如果访问的是/r/**的话,是要认证通过的,所以会让认证
http.logout().logoutUrl("/logout");配置了退出页面,认证成功后访问/logout可退出登录。
2.2.3 授权测试
用户认证通过去访问系统资源时spring security进行授权控制,判断用户是否有该资源的访问权限,如果有则继续访问,如果没有则拒绝访问。
下边测试授权功能:
1、配置用户拥有哪些权限。
在WebSecurityConfig类配置zhangsan拥有p1权限,lisi拥有p2权限。
2、指定资源与权限的关系。
什么是系统的资源?
比如:查询一个用户的信息,用户信息就是系统的资源,要访问资源需要通过URL,所以我们在controller中定义的每个http的接口就是访问资源的接口。
下边在controller中配置/r/r1需要p1权限,/r/r2需要p2权限。
hasAuthority('p1')表示拥有p1权限方可访问。
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")
@PreAuthorize("hasAnyAuthority('p1')")
public String r1() {
return "访问r1资源";
}
@RequestMapping("/r/r2")
@PreAuthorize("hasAnyAuthority('p2')")
public String r2() {
return "访问r2资源";
}
}
现在重启工程。
当访问以/r/开头的url时会判断用户是否认证,如果没有认证则跳转到登录页面,如果已经认证则判断用户是否具有该URL的访问权限,如果具有该URL的访问权限则继续,否则拒绝访问。
访问/r/r1,使用zhangsan登录可以正常访问,因为在/r/r1的方法上指定了权限p1,zhangsan用户拥有权限p1,所以可以正常访问。

访问/r/r2就不行,是因为zhangsan没有访问/r/r2的权限

注意:如果访问上不加@PreAuthorize,此方法没有授权控制。
整理授权的过程见下图所示:

BUG:在这里遇到个bug,一直显示org.springframework.xxxx程序包不存在
解决方式:
2.2.4 工作原理
通过测试认证和授权两个功能,我们了解了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的执行流程如下:

- 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
- 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证
- 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。
- SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。
- 可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring Security支持多种认证方式,因此ProviderManager维护着一个List
列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。
2.3 什么是0Auth2
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认证的例子,黑马程序员网站使用微信认证扫码登录的过程:

具体流程如下:
1、用户点击微信扫码
用户进入黑马程序的登录页面,点击微信的图标开打微信扫码界面。

微信扫码的目的是通过微信认证登录黑马程序员官网,黑马程序员网站需要从微信获取当前用户的身份信息才会让当前用户在黑马网站登录成功。
现在搞清楚几个概念:
资源:用户信息,在微信中存储。
资源拥有者:用户是用户信息资源的拥有者。
认证服务:微信负责认证当前用户的身份,负责为客户端颁发令牌。
客户端:客户端会携带令牌请求微信获取用户信息,黑马程序员网站即客户端,黑马网站需要在浏览器打开。
2、用户授权黑马网站访问用户信息
资源拥有者扫描二维码表示资源拥有者请求微信进行认证,微信认证通过向用户手机返回授权页面,如下图:
询问用户是否授权黑马程序员访问自己在微信的用户信息,用户点击“确认登录”表示同意授权,微信认证服务器会颁发一个授权码给黑马程序员的网站。
只有资源拥有者同意微信才允许黑马网站访问资源。
3、黑马程序员的网站获取到授权码
4、携带授权码请求微信认证服务器申请令牌
此交互过程用户看不到。
5、微信认证服务器向黑马程序员的网站响应令牌
此交互过程用户看不到。
6、黑马程序员网站请求微信资源服务器获取资源即用户信息。
黑马程序员网站携带令牌请求访问微信服务器获取用户的基本信息。
7、资源服务器返回受保护资源即用户信息
8、黑马网站接收到用户信息,此时用户在黑马网站登录成功。
理解了微信扫码登录黑马网站的流程,接下来认识Oauth2.0的认证流程,如下:
引自Oauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749

Oauth2包括以下角色:
1、客户端
本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:手机客户端、浏览器等。
上边示例中黑马网站即为客户端,它需要通过浏览器打开。
2、资源拥有者
通常为用户,也可以是应用程序,即该资源的拥有者。
A表示 客户端请求资源拥有者授权。
B表示 资源拥有者授权客户端即黑马网站访问自己的用户信息。
3、授权服务器(也称认证服务器)
认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌。
C 客户端即黑马网站携带授权码请求认证。
D认证通过颁发令牌。
4、资源服务器
存储资源的服务器。
E表示客户端即黑马网站携带令牌请求资源服务器获取资源。
F表示资源服务器校验令牌通过后提供受保护资源。
2.3.2 OAuth2在本项目中的应用
Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用Oauth2,本项目使用Oauth2实现如下目标:
1、学成在线访问第三方系统的资源。
本项目要接入微信扫码登录所以本项目要使用OAuth2协议访问微信中的用户信息。
2、外部系统访问学成在线的资源 。
同样当第三方系统想要访问学成在线网站的资源也可以基于OAuth2协议。
3、学成在线前端(客户端) 访问学成在线微服务的资源。
本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议进行认证。
2.3.3 OAuth2的授权模式
Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式,前边举的微信扫码登录的例子就是基于授权码模式,这四种模式中授权码模式和密码模式应用较多,本节使用Spring Security演示授权码模式、密码模式,其余两种请自行查阅相关资料。
2.3.3.1 授权码模式
OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,最终通过令牌去获取资源。
授权码模式简单理解是使用授权码去获取令牌,要想获取令牌先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取。
下图是授权码模式的交互图:
还以黑马网站微信扫码登录为例进行说明:
1、用户打开浏览器。
2、通过浏览器访问客户端即黑马网站。
3、用户通过浏览器向认证服务请求授权,请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址。
4、认证服务向资源拥有者返回授权页面。
5、资源拥有者亲自授权同意。
6、通过浏览器向认证服务发送授权同意。
7、认证服务向客户端地址重定向并携带授权码。
8、客户端即黑马网站收到授权码。
9、客户端携带授权码向认证服务申请令牌。
10、认证服务向客户端颁发令牌。
2.3.3.2 授权码模式测试
要想测试授权模式首先要配置授权服务器即上图中的认证服务器,需要配置授权服务及令牌策略。
1、从课程资料中拷贝 AuthorizationServer.java、TokenConfig.java到认证服务的config包下。
说明:AuthorizationServer用 @EnableAuthorizationServer 注解标识并继承AuthorizationServerConfigurerAdapter来配置OAuth2.0 授权服务器。
1)ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),
随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详细信息。
2)AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token services)。
3)AuthorizationServerSecurityConfigurer:用来配置令牌端点的安全约束.
2、TokenConfig为令牌策略配置类
暂时先使用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;
}
3、配置认证管理bean
Java
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
....
重启认证服务
1、get请求获取授权码
参数列表如下:
• client_id:客户端准入标识。
• response_type:授权码模式固定为code。
• scope:客户端权限。
• redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。

选择approve,点击Authrize

3、使用httpclient工具post申请令牌
参数列表如下
• client_id:客户端准入标识。
• client_secret:客户端秘钥。
• grant_type:授权类型,填写authorization_code,表示授权码模式
• code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
• redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
httpclient脚本如下:
Plain Text
### 授权码模式
### 第一步申请授权码(浏览器请求)/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
拿到令牌

1、access_token,访问令牌,用于访问资源使用。
2、token_type,bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时需要在head中加入bearer 空格 令牌内容
3、refresh_token,当令牌快过期时使用刷新令牌可以再次生成令牌。
4、expires_in:过期时间(秒)
5、scope,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。
2.3.3.3 密码模式
密码模式相对授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如下图:
1、资源拥有者提供账号和密码
2、客户端向认证服务申请令牌,请求中携带账号和密码
3、认证服务校验账号和密码正确颁发令牌。
开始测试:
1、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:资源拥有者密码。
2、授权服务器将令牌(access_token)发送给client
使用httpclient进行测试
Java
### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123

这种模式十分简单,但是却意味着直接将用户敏感信息泄漏给了client,因此这就说明这种模式只能用于client是我们自己开发的情况下。
2.3.3.4 本项目的应用方式
通过演示授权码模式和密码模式,授权码模式适合客户端和认证服务非同一个系统的情况,所以本项目使用授权码模式完成微信扫码认证。本项目采用密码模式作为前端请求微服务的认证方式。
2.4 JWT
2.4.1 普通令牌的问题
客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器将会校验令牌的合法性。
资源服务器如何校验令牌的合法性?
我们以OAuth2的密码模式为例进行说明:
1、客户端携带令牌访问资源服务获取资源。
2、资源服务远程请求认证服务校验令牌的合法性
3、如果令牌合法资源服务向客户端返回资源。
这里存在一个问题:
就是校验令牌需要远程请求认证服务,客户端的每次访问都会远程校验,执行性能低。
如果能够让资源服务自己校验令牌的合法性将省去远程请求认证服务的成本,提高了性能。如下图:
如何解决上边的问题,实现资源服务自行校验令牌。
令牌采用JWT格式即可解决上边的问题,用户认证通过后会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。
2.4.2 什么是JWT
什么是JWT?
JSON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任,它可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止内容篡改。官网:https://jwt.io/使用JWT可以实现无状态认证。
什么是无状态认证?
传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。
如下图,当用户访问应用服务,每个应用服务都会去服务器查看session信息,如果session中没有该用户则说明用户没有登录,此时就会重新认证,而解决这个问题的方法是Session复制、Session黏贴。
如果是基于令牌技术在分布式系统中实现认证则服务端不用存储session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从jwt解析出用户信息。这个过程就是无状态认证。
JWT令牌的优点:
1、jwt基于json,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过加密算法及数字签名技术,JWT防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
1、JWT令牌较长,占存储空间比较大。
下边是一个JWT令牌的示例:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQyNTQ2NzIsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijg4OTEyYjJkLTVkMDUtNGMxNC1iYmMzLWZkZTk5NzdmZWJjNiIsImNsaWVudF9pZCI6ImMxIn0.wkDBL7roLrvdBG2oGnXeoXq-zZRgE9IVV2nxd-ez_oA
JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz
-
header
头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)
一个例子如下:
下边是Header部分的内容

-
Payload
第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的信息字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。

-
Signature
第三部分是签名,此部分用于防止jwt内容被篡改。
这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明的签名算法进行签名。
JSON HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)base64UrlEncode(header):jwt令牌的第一部分。
base64UrlEncode(payload):jwt令牌的第二部分。
为什么JWT可以防止篡改?
第三部分使用签名算法对第一部分和第二部分的内容进行签名,常用的签名算法是 HS256,常见的还有md5,sha 等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容那么服务器验证签名就会失败,要想保证验证签名正确必须保证内容、密钥与签名前一致。
从上图可以看出认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造jwt令牌。
JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密和解密,非对称加密效率低但相比对称加密非对称加密更安全一些。
2.4.3 测试生成JWT令牌
在认证服务中配置jwt令牌服务,即可实现生成jwt格式的令牌,
@Configuration
public class TokenConfig {
private String SIGNING_KEY = "mq123";
@Autowired
TokenStore tokenStore;
// @Bean
// public TokenStore tokenStore() {
// //使用内存存储令牌(普通令牌)
// return new InMemoryTokenStore();
// }
@Autowired
private JwtAccessTokenConverter accessTokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@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

1、access_token,生成的jwt令牌,用于访问资源使用。
2、token_type,bearer是在RFC6750中定义的一种token类型,在携带jwt访问资源时需要在head中加入bearer jwt令牌内容
3、refresh_token,当jwt令牌快过期时使用刷新令牌可以再次生成jwt令牌。
4、expires_in:过期时间(秒)
5、scope,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。
6、jti:令牌的唯一标识。
我们可以通过check_token接口校验jwt令牌
###校验jwt令牌
POST {{auth_host}}/auth/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJzdHUxIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY2NDM3MTc4MCwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiZjBhM2NkZWItMzk5ZC00OGYwLTg4MDQtZWNhNjM4YWQ4ODU3IiwiY2xpZW50X2lkIjoiYzEifQ.qy46CSCJsH3eXWTHgdcntZhzcSzfRQlBU0dxAjZcsUw

2.4.4 携带令牌范文资源服务
拿到了jwt令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务,比如:内容管理服务,客户端申请到jwt令牌,携带jwt去内容管理服务查询课程信息,此时内容管理服务要对jwt进行校验,只有jwt合法才可以继续访问。如下图:

1、在内容管理服务的content-api工程中添加依赖
2、从资料中拷贝 资源服务配置类 中的 TokenConfig和ResouceServerConfig 到内容管理的api工程的config包下。
可以在ResouceServerConfig类中配置需要认证的url。
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/r/**","/course/**").authenticated()//所有/r/**的请求必须认证通过
.anyRequest().permitAll()
;
}
重启内容管理服务
又报找不到符号的错误,解决了好久,最后还是通过各种重启解决了
使用httpclient测试:
1、访问根据课程id查询课程接口
### 查询课程信息
GET http://localhost:63040/content/course/2

从返回信息可知当前没有认证。
下边携带JWT令牌访问接口:
1、申请jwt令牌
采用密码模式申请令牌。
### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123

2、携带jwt令牌访问资源服务地址
在请求头中添加Authorization,内容为Bearer 令牌,Bearer用于通过oauth2.0协议访问资源。
如果携带jwt令牌且jwt正确则正常访问资源服务的内容。
### 根据课程id查询课程信息
GET {{content_host}}/content/course/120
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2ODQwNzI1NzUsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijc3MjhkNjg2LWM4NGUtNDQ3Yy1hYzRlLTlmODZkYWM1NTMxOCIsImNsaWVudF9pZCI6IlhjV2ViQXBwIn0.PMqL9BypQvyyoYt0zpPphjMoFH2691NIfoeUk4s3UKY
成功查询到数据。

如果不正确则报令牌无效的错误:

2.4.5 测试获取用户身份
jwt令牌中记录了用户身份信息,当客户端携带jwt访问资源服务,资源服务验签通过后将前两部分的内容还原即可取出用户的身份信息,并将用户身份信息放在了SecurityContextHolder上下文,SecurityContext与当前线程进行绑定(内部用的就是threadlocal),方便获取用户身份。
还以查询课程接口为例,进入查询课程接口的代码中,添加获取用户身份的代码
@ApiOperation("课程查询接口")
@GetMapping("/course/{courseId}")
public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
System.out.println(principal);
return courseBaseInfoService.getCourseBaseById(courseId);
}
测试时需要注意:
1、首先在资源服务配置中指定安全拦截机制 /course/开头的请求需要认证,即请求/course/{courseId}接口需要携带jwt令牌且签证通过。
2、认证服务生成jwt令牌将用户身份信息写入令牌,目前还是将用户信息硬编码并暂放在内存中。
@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;
}
3、我们在使用密码模式生成jwt令牌时用的是zhangsan的信息,所以jwt令牌中存储了zhangsan的信息,那么在资源服务中应该取出zhangsan的信息才对。
重启content工程,打断点,看看能不能拿到用户的信息,拿到了用户登录的信息

2.5 网关认证
2.5.1 技术方案
到目前为止,测试通过了认证服务颁发jwt令牌,客户端携带jwt访问资源服务,资源服务对jwt的合法性进行验证。如下图:

仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示:

所有访问微服务的请求都要经过网关,在网关进行用户身份的认证可以将很多非法的请求拦截到微服务以外,这叫做网关认证
下边需要明确网关的职责:
1、网站白名单维护
针对不用认证的URL全部放行。
2、校验jwt的合法性。
除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。
3、路由转发
通过访问的url,再根据nacos路由到对应的微服务上
网关负责授权吗?
网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口。
2.5.2 实现网关认证
下边实现网关认证,实现以下职责:
1、网站白名单维护
针对不用认证的URL全部放行。
2、校验jwt的合法性。
除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。
1、在网关工程添加依赖
XML
<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>
2、拷贝课程资料下网关认证配置类到网关工程的config包下。
3、配置白名单文件security-whitelist.properties
/auth/**=认证地址
/content/open/**=内容管理公开访问接口
/media/open/**=媒资管理公开访问接口
重启网关工程,进行测试
1、申请令牌
2、通过网关访问资源服务
这里访问内容管理服务
GET {{gateway_host}}/content/course/120
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2ODQwNzI1NzUsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijc3MjhkNjg2LWM4NGUtNDQ3Yy1hYzRlLTlmODZkYWM1NTMxOCIsImNsaWVudF9pZCI6IlhjV2ViQXBwIn0.PMqL9BypQvyyoYt0zpPphjMoFH2691NIfoeUk4s3UKY
相应结果

当修改一点token的值,相应结果如下

注意:网关鉴权功能调试通过后,由于目前还没有开发认证功能,前端请求网关的URL不在白名单中间时会“没有认证”的错误,暂时在白名单中添加 全部放行配置,待认证功能开发完成再屏蔽全部放行配置,

由于是在网关处进行令牌校验,所以在微服务处不再校验令牌的合法性,修改内容管理服务的ResouceServerConfig类,屏蔽authenticated()。
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
// .antMatchers("/r/**", "/course/**").authenticated()//所有/r/**的请求必须认证通过
.anyRequest().permitAll();
}
3 用户认证
3.1 需求分析
至此我们了解了使用Spring Security进行认证授权的过程,本节实现用户认证功能。目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。本项目也要支持多种认证试。
3.2 连接用户中心数据库
3.2.1 连接数据库认证
基于的认证流程在研究Spring Security过程中已经测试通过,到目前为止用户认证流程如下:

认证所需要的用户信息存储在用户中心数据库,现在需要将认证服务连接数据库查询用户信息。
在研究Spring Security的过程中是将用户信息硬编码,如下:
@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;
}
我们要认证服务中连接用户中心数据库查询用户信息。
如何使用Spring Security连接数据库认证吗?
前边学习Spring Security工作原理时有一张执行流程图,如下图:

用户提交账号和密码由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。
查询DaoAuthenticationProvider的源代码如下:

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。

下边自定义UserDetailsService
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private XcUserMapper xcUserMapper;
/**
* @param s 用户名
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 根据用户名查数据库里的相关信息
LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
xcUserLambdaQueryWrapper.eq(XcUser::getUsername, s);
XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper);
// 没有查询到,说明用户不存在,返回空即可
if (user == null) {
// 框架会抛出异常的
return null;
}
// 查询到了,就封装数据,返回UserDetails即可
String[] authorities = {"test"}; // 暂时定义假数据
return User.withUsername(user.getUsername()).password(user.getPassword()).authorities(authorities).build();
}
}
数据库中的密码加过密的,用户输入的密码是明文,我们需要修改密码格式器PasswordEncoder,原来使用的是NoOpPasswordEncoder,它是通过明文方式比较密码,现在我们修改为BCryptPasswordEncoder,它是将用户输入的密码编码为BCrypt格式与数据库中的密码进行比对。
@Bean
public PasswordEncoder passwordEncoder() {
//密码为明文方式
// return NoOpPasswordEncoder.getInstance();
return new BCryptPasswordEncoder();
}
我们通过测试代码测试BCryptPasswordEncoder,如下:
public static void main(String[] args) {
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String rawPassword = "111111";
for (int i = 0; i < 5; i++) {
String curPassword = bCryptPasswordEncoder.encode(rawPassword);
System.out.println(curPassword);
boolean matches = bCryptPasswordEncoder.matches(rawPassword, curPassword);
System.out.println(matches);
}
boolean matches = bCryptPasswordEncoder.matches("abcd", "$2a$10$uTqn7UdWHzQW28J9voXJFedzO6HIutDB2nPWBr1X8gBaK/NMrERgi");
System.out.println(matches);
}
}
修改数据库中的密码为Bcrypt格式,并且记录明文密码,稍后申请令牌时需要。
由于修改密码编码方式还需要将客户端的密钥更改为Bcrypt格式.
@Override
public void configure(ClientDetailsServiceConfigurer clients)
throws Exception {
clients.inMemory()// 使用in-memory存储
.withClient("XcWebApp")// client_id
// .secret("XcWebApp")//客户端密钥
.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}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=zhangsan&password=123
首先数据库中肯定没又zhangsan这个用户,所以,肯定令牌申请不下来

结果

输入正确的用户名,但是密码输入错误,看看会返回什么结果
### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=t1&password=123

输入正确的账号密码
### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=t1&password=111111
返回JWT成功

3.2.2 扩展用户身份信息
用户表中存储了用户的账号、手机号、email,昵称、qq等信息,UserDetails接口只返回了username、密码等信息,如下:
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
我们需要扩展用户身份的信息,在jwt令牌中存储用户的昵称、头像、qq等信息。
如何扩展Spring Security的用户身份信息呢?
在认证阶段DaoAuthenticationProvider会调用UserDetailService查询用户的信息,这里是可以获取到齐全的用户信息的。由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路:
-
第一是可以扩展UserDetails,使之包括更多的自定义属性,
-
第二也可以扩展username的内容 ,比如存入json数据内容作为username的内容。
相比较而言,方案二比较简单还不用破坏UserDetails的结构,我们采用方案二。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private XcUserMapper xcUserMapper;
/**
* @param s 用户名
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 根据用户名查数据库里的相关信息
LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
xcUserLambdaQueryWrapper.eq(XcUser::getUsername, s);
XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper);
// 没有查询到,说明用户不存在,返回空即可
if (user == null) {
// 框架会抛出异常的
return null;
}
// 查询到了,就封装数据,返回UserDetails即可
String[] authorities = {"test"}; // 暂时定义假数据
// 用fastJSON讲user转成json字符串
String userJsonStr = JSON.toJSONString(user);
return User.withUsername(userJsonStr).password(user.getPassword()).authorities(authorities).build();
}
}
重启认证服务,重新生成令牌,生成成功。
我们可以使用check_token查询jwt的内容

可以看到user_name中的内容变多了

3.2.3 资源服务获取用户身份
下边编写一个工具类在各个微服务中去使用,获取当前登录用户的对象。
在content-api中定义此类:
@Slf4j
public class SecurityUtil {
public static XcUser getUser() {
try {
Object principalObj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principalObj instanceof String) {
String principal = principalObj.toString();
XcUser xcUser = JSON.parseObject(principal, XcUser.class);
xcUser.setPassword(null);
return xcUser;
}
} 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;
}
}
之所以,要建立一个静态内部类,是因为结构的设计不能再content中依赖auth工程,所以,只能定义一个静态内部类,因为要返回XcUser这个类的信息
下边在内容管理服务中测试此工具类,以查询课程信息接口为例:
@ApiOperation("根据课程id查询课程基础信息")
@GetMapping("/course/{courseId}")
public CourseBaseInfoDto getCourseBaseById(@PathVariable("courseId") Long courseId){
//取出当前用户身份
// Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
SecurityUtil.XcUser user = SecurityUtil.getUser();
System.out.println(user);
return courseBaseInfoService.getCourseBaseInfo(courseId);
}
重启内容管理服务:
1、启动认证服务、网关、内容管理服务
2、生成新的令牌
3、携带令牌访问内容管理服务的查询课程接口
最后可以看到,成功的拿到了用户很多的信息

3.3 支持认证方式多样
3.3.1 统一认证入口
目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。
基于当前研究的Spring Security认证流程如何支持多样化的认证方式呢?
1、支持账号和密码认证
采用OAuth2协议的密码模式即可实现。
2、支持手机号加验证码认证
用户认证提交的是手机号和验证码,并不是账号和密码。
3、微信扫码认证
基于OAuth2协议与微信交互,学成在线网站向微信服务器申请到一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过。
目前我们测试通过OAuth2的密码模式,用户认证会提交账号和密码,由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。
初始的UserDetailService是在spring security的配置类中配置的,因为要从数据库中获取信息,所以,就要实现这个类,并事项loadUserByUsername
在前边我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息。
在前边我们自定义了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;//验证码key
private String authType; // 认证的类型 password:用户名密码模式类型 sms:短信模式类型
private Map<String, Object> payload = new HashMap<>();//附加数据,作为扩展,不同认证类型可拥有不同的附加数据。如认证类型为短信时包含smsKey : sms:3d21042d054548b08477142bbca95cfa; 所有情况下都包含clientId
}
此时loadUserByUsername()方法可以修改如下:
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private XcUserMapper xcUserMapper;
/**
* @param s 用户名
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 转s这中json字符串成为AuthParamDto对象
AuthParamsDto authParamsDto = null;
try {
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
} catch (Exception e) {
log.debug("转换json字符串为对象时候出错");
throw new RuntimeException("认证请求数据格式不对");
}
// 根据用户名查数据库里的相关信息
LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
xcUserLambdaQueryWrapper.eq(XcUser::getUsername, authParamsDto.getUsername());
XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper);
// 没有查询到,说明用户不存在,返回空即可
if (user == null) {
// 框架会抛出异常的
return null;
}
// 查询到了,就封装数据,返回UserDetails即可
String[] authorities = {"test"}; // 暂时定义假数据
// 用fastJSON讲user转成json字符串
String userJsonStr = JSON.toJSONString(user);
return User.withUsername(userJsonStr).password(user.getPassword()).authorities(authorities).build();
}
}
原来的DaoAuthenticationProvider 会进行密码校验,现在重新定义DaoAuthenticationProviderCustom类,重写类的additionalAuthenticationChecks方法。
就是在下面的方法中进行密码的校验,现在,因为有的认证方式不需要密码,所以,要重写这个方法。

定义一个DaoAuthenticationProviderCustom类继承DaoAuthenticationProvider类,并且重写additionalAuthenticationChecks这个方法。还要将UserDetailsService住进去,通过这个来得到UserDetails
@Component
@Slf4j
public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {
@Autowired
@Override
public void setUserDetailsService(UserDetailsService userDetailsService) {
super.setUserDetailsService(userDetailsService);
}
/**
* @param userDetails 封装的用户的信息
* @param authentication 用户名和密码的令牌
* @throws AuthenticationException 抛出异常
*/
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
// 这里就不实现密码的验证
}
}
修改WebSecurityConfig类指定daoAuthenticationProviderCustom,相当于将我们自己写的DaoAuthenticationProvider注入到这个Spring Security上下文中去
@Autowired
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(daoAuthenticationProviderCustom);
}
此时可以重启认证服务,测试申请令牌接口,传入的账号信息改为json数据,如下:
################扩展认证请求参数后######################
###密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"stu1","authType":"password","password":"111111"}
测试拿到令牌之后,base64解码之后的结果,和我们要拿的数据是一样的

经过测试发现loadUserByUsername()方法可以正常接收到认证请求中的json数据。
有了这些认证参数我们可以定义一个认证Service接口去进行各种方式的认证。
定义用户信息,为了扩展性让它继承XcUser
@Data
public class XcUserExt extends XcUser {
}
定义认证Service接口
public interface AuthService {
/**
* @description 认证方法
* @param authParamsDto 认证参数
* @return com.xuecheng.ucenter.model.po.XcUser 用户信息
* @author Mr.M
* @date 2022/9/29 12:11
*/
XcUserExt execute(AuthParamsDto authParamsDto);
}
根据不同的认证接口的名称,在UserDetailsService中调用不同的认证Service的接口
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 转s这中json字符串成为AuthParamDto对象
AuthParamsDto authParamsDto = null;
try {
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
} catch (Exception e) {
log.debug("转换json字符串为对象时候出错");
throw new RuntimeException("认证请求数据格式不对");
}
// 开始认证,通过bean的名字得到相应的认证接口
String authType = authParamsDto.getAuthType();
String beanName = authType + "AuthService";
// 在spring中拿到bean
AuthService authService = applicationContext.getBean(beanName, AuthService.class);
authService.execute(authParamsDto);
// 根据用户名查数据库里的相关信息
LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
xcUserLambdaQueryWrapper.eq(XcUser::getUsername, authParamsDto.getUsername());
XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper);
// 没有查询到,说明用户不存在,返回空即可
if (user == null) {
// 框架会抛出异常的
return null;
}
// 查询到了,就封装数据,返回UserDetails即可
String[] authorities = {"test"}; // 暂时定义假数据
// 用fastJSON讲user转成json字符串
String userJsonStr = JSON.toJSONString(user);
return User.withUsername(userJsonStr).password(user.getPassword()).authorities(authorities).build();
}
到此我们基于Spring Security认证流程修改为如下:

3.3.2 实现账号密码认证
上节定义了AuthService认证接口,下边实现该接口实现账号密码认证,在相应的AuthService中实现不同类型的登录方式的认证。
@Service("passwordAuthService")
public class PasswordAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
PasswordEncoder passwordEncoder;
/**
* @param authParamsDto 认证参数
* @return 返回一个统一的信息
*/
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
// 验证看看用户在数据库里是不是空
LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
xcUserLambdaQueryWrapper.eq(XcUser::getUsername, authParamsDto.getUsername());
XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper);
// 没有查询到,说明用户不存在,返回空即可
if (user == null) {
// 用户不存在
throw new RuntimeException("账号或密码不正确");
}
// TODO:验证码
// 验证密码的有效性
String databasePassword = user.getPassword();
String inputPassword = authParamsDto.getPassword();
boolean matches = passwordEncoder.matches(inputPassword, databasePassword);
if (!matches) {
throw new RuntimeException("账号或密码不正确");
}
// 有效的话,就将从数据库中查到的用户数据,拷贝给XcUserExt
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(user, xcUserExt);
return xcUserExt;
}
}
修改UserServiceImpl类,根据认证方式使用不同的认证bean
@Service
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private XcUserMapper xcUserMapper;
@Autowired
private ApplicationContext applicationContext;
/**
* @param s 用户名
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
// 转s这中json字符串成为AuthParamDto对象
AuthParamsDto authParamsDto = null;
try {
authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
} catch (Exception e) {
log.debug("转换json字符串为对象时候出错");
throw new RuntimeException("认证请求数据格式不对");
}
// 开始认证,通过bean的名字得到相应的认证接口
String authType = authParamsDto.getAuthType();
String beanName = authType + "AuthService";
// 在spring中拿到bean
AuthService authService = applicationContext.getBean(beanName, AuthService.class);
XcUserExt xcUserExt = authService.execute(authParamsDto);
// 封装UserDetails
return getUserPrincipal(xcUserExt);
}
public UserDetails getUserPrincipal(XcUserExt user) {
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
String[] authorities = {"p1"};
String password = user.getPassword();
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
return User.withUsername(userString).password(password ).authorities(authorities).build();
}
}
重启认证服务,测试申请令牌接口
1、测试账号和密码都正确的情况是否可以申请令牌成功。
密码正确,代码运行正常,拿到令牌


2、测试密码错误的情况。

3、测试账号不存在情况。

3.4 验证码服务
3.4.1 部署验证码服务工程
在认证时一般都需要输入验证码
验证码有什么用?
验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。
为了保护系统的安全在一些比较重要的操作都需要验证码。

验证码的类型也有很多:图片、语音、手机短信验证码等。
本项目创建单独的验证码服务为各业务提供验证码的生成、校验等服务。
拷贝课程资料目录xuecheng-plus-checkcode验证码服务工程到自己的工程目录。
定义nacos配置文件 checkcode-dev.yaml
server:
servlet:
context-path: /checkcode
port: 63075
配置redis-dev.yaml,保存redis服务启动
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
3.4.2 验证码接口测试
验证码服务对外提供的接口有:
1、生成验证码
2、校验验证码。

验证码服务如何生成并校验验证码?
拿图片验证码举例:
1、先生成一个指定位数的验证码,根据需要可能是数字、数字字母组合或文字。
2、根据生成的验证码生成一个图片并返回给页面
3、给生成的验证码分配一个key,将key和验证码一同存入缓存。这个key和图片一同返回给页面。
4、用户输入验证码,连同key一同提交至认证服务。
5、认证服务拿key和输入的验证码请求验证码服务去校验
6、验证码服务根据key从缓存取出正确的验证码和用户输入的验证码进行比对,如果相同则校验通过,否则不通过。

根据接口分析,验证码服务接口如下:
1、生成验证码接口
Plain Text
### 申请验证码
POST {{checkcode_host}}/checkcode/pic
生成了key和图片的内容

在浏览器中打开图片

2、校验验证码接口
### 校验验证码
POST {{checkcode_host}}/checkcode/verify?key=checkcode4506b95bddbe46cdb0d56810b747db1b&code=70dl
输入错误的验证码

输入正确的验证码,验证正确

3.5 账号密码认证
3.5.1 需求分析
到目前为止账号和密码认证所需要的技术、组件都已开发完毕,下边实现账号密码认证,输出如下图:

执行流程,在网关进行了统一的拦截,如果不在白名单中就拦截,检查是否有token存在,来判断是不是有认证,如果没有认证的话,就去auth服务中进行认证,认证之后,auth就会生成jwt令牌,然后又到网关,网关验证jwt是否是有效的,如果有效的话,再转发给其他的微服务。

启动认证服务,验证码服务,网关服务,访问www.51xuecheng.com能看到验证码已经可以显示了
如上图所示。
3.5.2 账号密码认证开发
现在验证码已经能显示了,在生成验证码的时候已经将验证码的值存到了相应的key中了。
在认证服务中,只需要通过调用验证码服务的接口即可完成验证码的认证。
涉及到了微服务间的调用,就要用到了feign。
1、在认证服务定义远程调用验证码服务的接口
@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;
}
};
}
}
添加熔断的配置

在启动类上添加feign的注解,并且设置feign的包所在的位置

2、完善PasswordAuthServiceImpl
public XcUserExt execute(AuthParamsDto authParamsDto) {
// 验证验证码
String checkcode = authParamsDto.getCheckcode();
String checkcodekey = authParamsDto.getCheckcodekey();
// 检验验证码和验证码在redis中的key是不是空
if (StringUtils.isBlank(checkcodekey) || StringUtils.isBlank(checkcode)) {
throw new RuntimeException("验证码输入为空");
}
Boolean verify = checkCodeClient.verify(checkcodekey, checkcode);
if (verify == null || !verify) {
throw new RuntimeException("验证码有误");
}
// 验证看看用户在数据库里是不是空
LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>();
xcUserLambdaQueryWrapper.eq(XcUser::getUsername, authParamsDto.getUsername());
XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper);
// 没有查询到,说明用户不存在,返回空即可
if (user == null) {
// 用户不存在
throw new RuntimeException("账号或密码不正确");
}
// 验证密码的有效性
String databasePassword = user.getPassword();
String inputPassword = authParamsDto.getPassword();
boolean matches = passwordEncoder.matches(inputPassword, databasePassword);
if (!matches) {
throw new RuntimeException("账号或密码不正确");
}
// 有效的话,就将从数据库中查到的用户数据,拷贝给XcUserExt
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(user, xcUserExt);
return xcUserExt;
}
小技巧:目前账号密码方式添加了验证码校验,为了后期获取令牌方便可以重新定义一个不需要验证码校验的认证类AuthService ,AuthService 中去掉验证码的校验,方便生成令牌。
3.5.3 账号密码认证测试
1、使用浏览器访问 http://www.51xuecheng.cn/sign.html

2、首先测试验证码,分别输入正确的验证码和错误的验证码进行测试
错误验证码

正确验证码

3、输入正确的账号密码和错误的账号密码进行测试


登录成功将jwt令牌存储cookie.
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 请求获取授权码
阅读微信接口文档内容即可
4.2 准备开发环境
4.2.1 添加应用
1、注册微信开放平台

2、添加应用
进入网站应用,添加应用
3、添加应用需要指定一个外网域名作为微信回调域名
审核通过后,生成app密钥。
获取不到,就用尚硅谷提供的?
4.2.2 内网穿透
我们的开发环境在局域网,微信回调指向一个公网域名。
如何让微信回调请求至我们的开发计算机上呢?
可以使用内网穿透技术,什么是内网穿透?
内网穿透简单来说就是将内网外网通过隧道打通,让内网的数据让外网可以获取。比如常用的办公室软件等,一般在办公室或家里,通过拨号上网,这样办公软件只有在本地的局域网之内才能访问,那么问题来了,如果是手机上,或者公司外地的办公人员,如何访问到办公软件呢?这就需要内网穿透工具了。开启隧道之后,网穿透工具会分配一个专属域名/端口,办公软件就已经在公网上了,在外地的办公人员可以在任何地方愉快的访问办公软件了~~

1、在内网穿透服务器上开通隧道,配置外网域名,配置穿透内网的端口即本地电脑上的端口。

这里我们配置认证服务端口,最终实现通过外网域名访问本地认证服务。
2、在本地电脑上安装内网穿透的工具,工具上配置内网穿透服务器隧道token。
市面上做内网穿透的商家很多,需要时可以查阅资料了解下。
实际上内网穿透要映射到8160
选择natcpp内网穿透
搭建成功

接入微信登录
4.3.1 接入分析
根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程如下:

本项目认证服务需要做哪些事?
1、需要定义接口接收微信下发的授权码。
2、收到授权码调用微信接口申请令牌。
3、申请到令牌调用微信获取用户信息
4、获取用户信息成功将其写入本项目用户中心数据库。
5、最后重定向到浏览器自动登录。
4.3.2 定义接口
参考接口规范中“请求获取授权码” 定义接收微信下发的授权码接口,
定义WxLoginController类,如下:
@Controller
@Slf4j
public class WxLoginController {
@RequestMapping("/wxLogini")
public String wxLogin(String code, String state) {
log.debug("微信扫码回调,code:{},state:{}",code,state);
//请求微信申请令牌,拿到令牌查询用户信息,将用户信息写入本项目数据库
XcUser xcUser = new XcUser();
//暂时硬编写,目的是调试环境
xcUser.setUsername("t1");
if(xcUser==null){
return "redirect:http://www.51xuecheng.cn/error.html";
}
String username = xcUser.getUsername();
return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
}
}
这个登录会被Spring security的loadUserByUsername执行,将内容转化为json字符串的形式,在代码中转成了AuthParamsDto对象,然后根据传的认证类型,调用不同的AuthService实现类,在实现类里完成认证的操作,在这里只是填了假数据,然后就返回在数据库里查询到的数据,然后就封装成了UserDetails了,然后就生成了jwt令牌。
定义微信认证的service
@Service("wxAuthService")
public class WxAuthServiceImpl implements AuthService {
@Autowired
XcUserMapper xcUserMapper;
/**
* @param authParamsDto 认证参数
* @return
*/
@Override
public XcUserExt execute(AuthParamsDto authParamsDto) {
String username = authParamsDto.getUsername();
XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(user, xcUserExt);
return xcUserExt;
}
}
在启动类中定义RestTemplate
@Bean
RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate(new OkHttp3ClientHttpRequestFactory());
return restTemplate;
}
4.3.3 接口环境测试
接口定义好下边进行测试下,主要目的是测试接口调度的环境。
运行网关、认证、验证码服务
1、启动内网穿透工具
最后是没有用内网穿透的

2、在/wxLogin接口中打断点

3、打开前端微信扫码页面
进行扫码

显示二维码

自己扫以下,看看会不会进入到断点处,也成功的进入到了断点处

扫码之后,自动登录成功了

4.3.4 接入微信认证
上述的代码是写死的,应该更改。
当用户扫码之后要做以下的步骤
- 请求微信申请令牌
- 拿到令牌查询用户信息
- 将用户信息写入本项目数据库
这三个方法写入一个接口中,定义接口WxAuthService,
public interface WxAuthService {
public XcUser wxAuth(String code);
}
在实现类中实现这个接口,
public XcUser wxAuth(String code) {
// 申请令牌
if (StringUtils.isBlank(code)) {
throw new RuntimeException("没有获得授权");
}
Map<String, String> wxToken = getWxToken(code);
String accessToken = wxToken.get("access_token");
String openid = wxToken.get("openid");
// 获取用户数据
Map<String, String> userInformation = getUserInformation(accessToken, openid);
// 写入数据库
XcUser xcUser = currentProxy.writeWxUserInfoToDB(userInformation);
return xcUser;
}
@Value("${weixin.appid}")
private String appid;
@Value("${weixin.secret}")
private String secret;
@Autowired
RestTemplate restTemplate;
private Map<String, String> getWxToken(String code) {
// 构造请求url
String urlTemplate = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
String url = String.format(urlTemplate, appid, secret, code);
log.info("调用微信接口申请access_token, url:{}", url);
// 远程调用微信接口
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
String wxResponse = exchange.getBody();
// 将wxResponse转化为Map
Map<String, String> map = JSON.parseObject(wxResponse, Map.class);
return map;
}
private Map<String, String> getUserInformation(String accessToken,String openid) {
// 生成url
String urlTemplate = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s";
String url = String.format(urlTemplate, accessToken, openid);
// 远程调用接口
ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
String resultJson = new String(Objects.requireNonNull(exchange.getBody())
.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
// 将json转化为java对象
Map<String, String> map = JSON.parseObject(resultJson, Map.class);
return map;
}
@Autowired
private XcUserRoleMapper xcUserRoleMapper;
@Transactional
public XcUser writeWxUserInfoToDB(Map<String, String> userInfoMap) {
// 要向user和role表中插入信息
// 看看是否已经在数据库中存在了
XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, userInfoMap.get("unionid")));
if (xcUser != null) {
log.debug("用户已经在数据库中存在, id:{}", xcUser.getId());
return xcUser;
}
// 向xc_user表中插入数据
XcUser user = new XcUser();
String uuid = UUID.randomUUID().toString();
user.setId(uuid);
user.setWxUnionid(userInfoMap.get("unionid"));
user.setNickname(userInfoMap.get("nickname"));
user.setUserpic(userInfoMap.get("headimgurl"));
user.setName(userInfoMap.get("nickname"));
user.setUsername(userInfoMap.get("unionid"));
user.setPassword(userInfoMap.get("unionid"));
user.setUtype("101001");//学生类型
user.setStatus("1");//用户状态
user.setCreateTime(LocalDateTime.now());
int insert = xcUserMapper.insert(user);
if (insert < 0) {
log.error("插入数据库失败");
}
// 向关系表中插入数据
XcUserRole xcUserRole = new XcUserRole();
xcUserRole.setId(UUID.randomUUID().toString());
xcUserRole.setUserId(uuid);
xcUserRole.setRoleId("17");//学生角色
xcUserRoleMapper.insert(xcUserRole);
return user;
}
完善微信登录的接口:
@RequestMapping("/wxLogin")
public String wxLogin(String code, String state) {
log.debug("微信扫码回调,code:{},state:{}",code,state);
//请求微信申请令牌,拿到令牌查询用户信息,将用户信息写入本项目数据库
XcUser user = wxAuthService.wxAuth(code);
if(user == null){
return "redirect:http://www.51xuecheng.cn/error.html";
}
String username = user.getUsername();
return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
}
测试保存用户信息
扫描网站的二维码,扫描成功

用户手机端授权,就自动登录了

信息也插入到了数据库的表中,测试通过

5 用户授权
5.1 RBAC
如何实现授权?业界通常基于RBAC实现授权。
RBAC分为两种方式:
基于角色的访问控制(Role-Based Access Control)
基于资源的访问控制(Resource-Based Access Control)
角色的访问控制(Role-Based Access Control)是按角色进行授权,比如:主体的角色为总经理可以查询企业运营报表,查询员工工资信息等,访问控制流程如下:

根据上图中的判断逻辑,授权代码可表示如下:
if(主体.hasRole("总经理角色id")){
查询工资
}
如果上图中查询工资所需要的角色变化为总经理和部门经理,此时就需要修改判断逻辑为“判断用户的角色是否是总经理或部门经理”,修改代码如下:
if(主体.hasRole("总经理角色id") || 主体.hasRole("部门经理角色id")){
查询工资
}
根据上边的例子发现,当需要修改角色的权限时就需要修改授权的相关代码,系统可扩展性差。
基于资源的访问控制(Resource-Based Access Control)
是按资源(或权限)进行授权,比如:用户必须具有查询工资权限才可以查询员工工资信息等,访问控制流程如下:

根据上图中的判断,授权代码可以表示为:
if(主体.hasPermission("查询工资权限标识")){
查询工资
}
优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强。
5.2 资源服务授权流程
本项目在资源服务内部进行授权,基于资源的授权模式,因为接口在资源服务,通过在接口处添加授权注解实现授权。
1、首先配置nginx代理
http {
server_names_hash_bucket_size 64;
...
#前端开发服务
upstream uidevserver{
server 127.0.0.1:8601 weight=10;
}
server {
listen 80;
server_name teacher.51xuecheng.cn;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
#location / {
# alias D:/itcast2022/xc_edu3.0/code_1/dist/;
# index index.html index.htm;
#}
location / {
proxy_pass http://uidevserver;
}
location /api/ {
proxy_pass http://gatewayserver/;
}
}
加载nginx 配置。
2、在资源服务集成Spring Security
在需要授权的接口处使用@PreAuthorize("hasAuthority('权限标识符')")进行控制
下边代码指定/course/list接口需要拥有xc_teachmanager_course_list 权限。
@ApiOperation("课程查询接口")
@PreAuthorize("hasAnyAuthority('xc_teachermanager_course_list')") // 拥有课程列表查询的接口方可查询
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams,
@RequestBody(required = false)QueryCourseParamsDto queryCourseParams) {
PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParams);
return result;
}
设置了@PreAuthorize表示执行此方法需要授权,如果当前用户请求接口没有权限则抛出异常,访问一下 教学机构。


org.springframework.security.access.AccessDeniedException: 不允许访问。
因为没有权限,但是报错不是很明显,所以,要在统一异常类中处理此异常。
但是又因为这个错误的异常是Spring.security包下报的,所以,如果要在自定义异常类中捕获这个异常的话,就要引入security的包,但是又因为其他的微服务模块都引入了base这个包,其他的微服务模块就也会引入security的包,会造成一些污染,所以不能在base这个模块中引入security的包。那么怎么才能捕获到这个异常呢,可以从异常信息入手,所有的异常肯定是Exception的子类。
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse customException(Exception e) {
log.error("【系统异常】{}", e.getMessage(), e);
e.printStackTrace();
if ("不允许访问".equals(e.getMessage())) {
return new RestErrorResponse("没有操作此功能的权限");
}
return new RestErrorResponse(CommonError.UNKNOWN_ERROR.getErrMessage());
}
测试
4、重启资源服务进行测试
当用户没有权限时页面提示:没有操作此功能的权限。

原理:当用户登录成功后,都会有一个jwt令牌,这个存储到cookie中,当去访问其他页面的时候也都会带上这个cookie,然后添加了授权的那个注解方法,就会解析cookie中的jwt,然后查看里边的权限列表


5.3 授权相关的数据模型
如何给用户分配权限呢?
首先要学习数据模型,本项目授权相关的数据表如下:

说明如下:
xc_user:用户表,存储了系统用户信息,用户类型包括:学生、老师、管理员等
xc_role:角色表,存储了系统的角色信息,学生、老师、教学管理员、系统管理员等。
xc_user_role:用户角色表,一个用户可拥有多个角色,一个角色可被多个用户所拥有
xc_menu:模块表,记录了菜单及菜单下的权限
xc_permission:角色权限表,一个角色可拥有多个权限,一个权限可被多个角色所拥有
本项目教学阶段不再实现权限定义及用户权限分配的功能,权限分配的界面原型如下图所示:

本项目要求掌握基于权限数据模型(5张数据表),要求在数据库中操作完成给用户分配权限、查询用户权限等需求。
1、查询用户所拥有的权限
select m.code from xc_menu m where m.id in (select p.menu_id from xc_permission p where p.role_id in (select ur.role_id from xc_user_role ur where user_id in (select u.id from xc_user u where username = 't1')));
2、给用户分配权限
1)添加权限
查询用户的id
查询权限的id
查询用户的角色,如果没有角色需要先给用户指定角色
向角色权限表添加记录
2)删除用户权限
本项目是基于角色分配权限,如果要删除用户的权限可以给用户换角色,那么新角色下的权限就是用户的权限;如果不换用户的角色可以删除角色下的权限即删除角色权限关系表相应记录,这样操作是将角色下的权限删除,属于该角色的用户都将删除此权限。
5.4 查询用户权限
使用Spring Security进行授权,首先在生成jwt前会查询用户的权限,如下图:
在使用Spring Security的时候,我们在登录的时候,会调用UserDetailsService方法中的loadUserByUsername这个方法,然后在这个方法中进行用户身份认证,如果认证成功的话,就会封装UserDetails封装UserDetails的过程也就是生成jwt的过程,所以权限的赋予要在认证之后完成。

接下来需要修改UserServiceImpl和PasswordAuthServiceImpl从数据库查询用户的权限。
@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);
2、修改PasswordAuthServiceImpl
修改UserServiceImpl类的getUserPrincipal方法,查询权限信息
public UserDetails getUserPrincipal(XcUserExt user) {
// 拿到当前用户所拥有的权限
List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(user.getId());
List<String> permissionList = new ArrayList<>();
if (xcMenus.size() > 0) {
for (XcMenu xcMenu : xcMenus) {
permissionList.add(xcMenu.getCode());
}
}
String[] authorities = permissionList.toArray(new String[0]);
//用户权限,如果不加报Cannot pass a null GrantedAuthority collection
// String[] authorities = {"p1"};
String password = user.getPassword();
//为了安全在令牌中不放密码
user.setPassword(null);
//将user对象转json
String userString = JSON.toJSONString(user);
//创建UserDetails对象
return User.withUsername(userString).password(password ).authorities(authorities).build();
}
5.5 授权测试
以上实现了认证时从数据库查询用户的权限,下边进行用户授权测试。
重启认证服务,使用内容管理课程列表查询为例,代码如下:

用户拥有xc_teachmanager_course_list权限方可访问课程查询接口。
以用户stu1为例,有这个权限

以用户t1为例,他没有查看的权限

5.6 细粒度划分
5.6.1 什么是细粒度划分
什么是细粒度划分?
教学机构在维护课程时只允许维护本机构的课程,教学机构细粒度授权过程如下:
1)获取当前登录的用户身份
2)得到用户所属教育机构的Id
3)查询该教学机构下的课程信息
最终实现了用户只允许查询自己机构的课程信息。
根据公司Id查询课程,流程如下:
1)教学机构用户登录系统,从用户身份中取出所属机构的id
在用户表中设计了company_id字段存储该用户所属的机构id.
2)接口层取出当前登录用户的身份,取出机构id
3)将机构id传入service方法。
4)service方法将机构id传入Dao方法,最终查询出本机构的课程信息。
代码实现如下:
@ApiOperation("课程查询接口")
@PreAuthorize("hasAnyAuthority('xc_teachmanager_course_list')") // 拥有课程列表查询的接口方可查询
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams,
@RequestBody(required = false)QueryCourseParamsDto queryCourseParams) {
// 实现细粒度划分,通过控制companyId的值来控制能展示那些数据
// 从principal中拿到User信息
SecurityUtil.XcUser user = SecurityUtil.getUser();
String companyIdStr = user.getCompanyId();
Long companyId = null;
if (!StringUtils.isEmpty(companyIdStr)) {
companyId = Long.valueOf(companyIdStr);
}
PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(companyId, pageParams, queryCourseParams);
return result;
}
Service方法如下:
public PageResult<CourseBase> queryCourseBaseList(Long companyId, PageParams pageParams, QueryCourseParamsDto queryCourseParamsDto) {
// 分页查询的测试
LambdaQueryWrapper<CourseBase> lambdaQueryWrapper = new LambdaQueryWrapper<>();
// 条件拼接
lambdaQueryWrapper.like(StringUtils.isNotEmpty(queryCourseParamsDto.getCourseName()), CourseBase::getName, queryCourseParamsDto.getCourseName());
lambdaQueryWrapper.eq(StringUtils.isNotEmpty(queryCourseParamsDto.getAuditStatus()), CourseBase::getAuditStatus, queryCourseParamsDto.getAuditStatus());
lambdaQueryWrapper.eq(StringUtils.isNotEmpty(queryCourseParamsDto.getPublishStatus()), CourseBase::getStatus, queryCourseParamsDto.getPublishStatus());
lambdaQueryWrapper.eq(CourseBase::getCompanyId, companyId);
// 分页参数
Page<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
// 分页
Page<CourseBase> pageResult = courseBaseMapper.selectPage(page, lambdaQueryWrapper);
// 获取数据
List<CourseBase> items = pageResult.getRecords();
long total = pageResult.getTotal();
PageResult<CourseBase> result = new PageResult<>(items, total, pageParams.getPageNo(), pageParams.getPageSize());
return result;
}
5.6.3 教学机构细粒度授权测试
使用一个教学机构的用户登录项目,并且此用户具有查询课程的权限。
手机修改数据库指定用户归属到一个机构中,涉及以下数据表:
xc_company为机构表
xc_company_user为机构用户关系表
xc_user表中有company_id字段。
从表中可以看到,t1用户可以看到课程信息,stu1不能看到课程信息。

t1用户

stu1用户


6 实战
6.1 找回密码
需求:忘记密码需要找回,可以通过手机号找回密码,通过邮箱找回密码以及人工通道。
界面访问地址:http://www.51xuecheng.cn/findpassword.html

接口:
手机验证码:/api/checkcode/phone?param1=手机号
邮箱验证码:/api/checkcode/phone?param1=电子邮箱地址
找回密码:/api/auth/findpassword
请求
{
cellphone:'',
email:'',
checkcodekey:'',
checkcode:'',
confirmpwd:'',
password:''
}
验证码的生成,我这里实现的都是将随机生成四个英文字符的验证码,然后将验证码存储再redis中,如果要输入验证码的话,要手动的从redis中拿到验证码。
首先定义相应结果的模型类,在checkcode中定义,之前的课程中已经定义过了,所以不需要再此定义,这个还是不要乱改,因为不知道前端到底拿到的字段的名字是什么,因为要将redis中的key返回给findpassword的接口中,如果更改了响应结果的模型类,有可能后端接受不到redis中的key,就没办法进行验证。
这里引入了hutool的工具包,很方便的就校验了手机号和邮箱的格式是否正确
引入依赖:
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.4</version>
</dependency>
没有定义接口,直接再controller中实现了方法,不是很规范。
@PostMapping("/phone")
public CheckCodeResultDto sendVerifyCode(@RequestParam("param1") String param) {
// 判断是邮箱或者手机号的格式是不是正确
boolean isMobile = Validator.isMobile(param);
boolean isEmail = Validator.isEmail(param);
if (!isEmail && !isMobile) {
XuechengPlusException.cast("手机号或邮箱格式不正确");
}
// 生成验证码,并存在redis中
CheckCodeResultDto checkCodeResultDto = sendCodeService.sendCode(param);
return checkCodeResultDto;
}
找回密码
这里涉及到了微服务之间的调用,需要定义feignclient,但是在之前已经写过了,所以不用自己手动写
1.首先在dto包中,定义接受参数的模型类,这个可以根据文档中的参数中定义出来,切记不要乱改大小写
@Data
@AllArgsConstructor
@NoArgsConstructor
public class FindPasswordDto {
private String cellphone;
private String email;
private String checkcodekey;
private String checkcode;
private String confirmpwd;
private String password;
}
service、controller方法的实现
这里本来是想要判断一下输入的密码是否和数据库中存放的密码是否一致,后来发现,在忘记密码的输入框中,并没有输入旧密码的表单。所以,代码注释了
@RestController
@Slf4j
public class FindPasswordController {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
CheckCodeClient checkCodeClient;
@PostMapping("/findpassword")
public void findPassword(@RequestBody FindPasswordDto findPasswordDto) {
String checkCodeKey = findPasswordDto.getCheckcodekey();
String checkCode = findPasswordDto.getCheckcode();
Boolean isValid = checkCodeClient.verify(checkCodeKey, checkCode);
if (!isValid) {
throw new RuntimeException("验证码有误,或者是熔断降级了");
}
// 判断密码是否一样
String password = findPasswordDto.getPassword();
String confirmPwd = findPasswordDto.getConfirmpwd();
String email = findPasswordDto.getEmail();
String cellPhone = findPasswordDto.getCellphone();
if (StringUtils.isBlank(password) || StringUtils.isBlank(confirmPwd) || !password.equals(confirmPwd)) {
throw new RuntimeException("密码不能为空,或两次密码不一致");
}
// 判断是否和数据库中的原密码一致
// LambdaQueryWrapper<XcUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();
// lambdaQueryWrapper.eq(!StringUtils.isBlank(email), XcUser::getEmail, email);
// lambdaQueryWrapper.eq(!StringUtils.isBlank(cellPhone), XcUser::getCellphone, cellPhone);
// XcUser user = xcUserMapper.selectOne(lambdaQueryWrapper);
// String passwordInDB = user.getPassword();
//
// boolean matches = passwordEncoder.matches(password, passwordInDB);
// if (!matches) {
// throw new RuntimeException("和数据库中的密码不一致");
// }
LambdaQueryWrapper<XcUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(!StringUtils.isBlank(email), XcUser::getEmail, email);
lambdaQueryWrapper.eq(!StringUtils.isBlank(cellPhone), XcUser::getCellphone, cellPhone);
XcUser user = xcUserMapper.selectOne(lambdaQueryWrapper);
// 如果一致的话,就更新密码
String newPassword = passwordEncoder.encode(password);
XcUser newUser = new XcUser();
BeanUtils.copyProperties(user, newUser);
newUser.setPassword(newPassword);
xcUserMapper.updateById(newUser);
}
}
这里遇到一个bug,就是在这个方法中打断点,然后,测试的时候,一致访问出错,响应码是403,看浏览器的报错,网上找资料,是跨站请求伪造的原因,Spring Security默认是开启了CSRF的保护的,要关闭。增加这个http.csrf().disable()
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
.anyRequest().permitAll()//其它请求全部放行
.and()
.formLogin().successForwardUrl("/login-success");//登录成功跳转到/login-success
http.logout().logoutUrl("/logout");//退出地址
}
测试
这里就不分开测了,一起测
findpassword打断点

忘记密码测试,因为这里我用的是我真是的手机号码和邮箱,打个码。

发送验证码,成功

redis中也能看到验证码

找回密码,进入到断点处了

最后用修改的密码,也能登录成功

6.2 注册
需求:为学生提供注册入口,通过此入口注册的用户为学生用户。
界面访问地址:http://www.51xuecheng.cn/register.html
界面

接口:
-
手机验证码:/api/checkcode/phone?param1=手机号
在忘记密码的时候已经写过了
-
注册:/api/auth/register
从前端过来的请求携带的json数据
{ cellphone:'', username:'', email:'', nickname:'', password:'', confirmpwd:'', checkcodekey:'', checkcode:'' }在auth下的dto下创建一个接受模型类
@Data @AllArgsConstructor @NoArgsConstructor public class RegisterParamDto { private String cellphone; private String username; private String email; private String nickname; private String password; private String confirmpwd; private String checkcodekey; private String checkcode; }在controller下创建一个 UserRegistration.java的类,在这里边编写注册的代码,就不再service中了,不是很规范,但是容忍一下
@RestController @Slf4j public class UserRegistration { @Autowired CheckCodeClient checkCodeClient; @Autowired XcUserMapper xcUserMapper; @Autowired XcUserRoleMapper xcUserRoleMapper; @Autowired PasswordEncoder passwordEncoder; @PostMapping("/register") @Transactional public void userRegistration(@RequestBody RegisterParamDto registerParamDto) { // 1、校验验证码,如果不一致则抛出异常 String checkcodekey = registerParamDto.getCheckcodekey(); String checkcode = registerParamDto.getCheckcode(); Boolean isEqual = checkCodeClient.verify(checkcodekey, checkcode); if (!isEqual) { XuechengPlusException.cast("验证码不正确"); } // 2、校验两次密码是否一致,如果不一致则抛出异常 String password = registerParamDto.getPassword(); String confirmpwd = registerParamDto.getConfirmpwd(); if (StringUtils.isBlank(password) || StringUtils.isBlank(confirmpwd) || !password.equals(confirmpwd)) { XuechengPlusException.cast("两次密码输入不一致"); } // 3、校验用户是否存在,如果存在则抛出异常 // 根据手机号和email来查询 String cellphone = registerParamDto.getCellphone(); String email = registerParamDto.getEmail(); LambdaQueryWrapper<XcUser> xcUserLambdaQueryWrapper = new LambdaQueryWrapper<>(); xcUserLambdaQueryWrapper.eq(!StringUtils.isBlank(cellphone), XcUser::getCellphone, cellphone); xcUserLambdaQueryWrapper.eq(!StringUtils.isBlank(email), XcUser::getEmail, email); XcUser user = xcUserMapper.selectOne(xcUserLambdaQueryWrapper); if (user != null) { XuechengPlusException.cast("用户已经存在,不能注册"); } // 4、向用户表、用户角色关系表添加数据。角色为学生角色。 // 向用户表添加数据 XcUser newUser = new XcUser(); String userId = UUID.randomUUID().toString(); newUser.setId(userId); newUser.setCellphone(registerParamDto.getCellphone()); newUser.setUsername(registerParamDto.getUsername()); newUser.setNickname(registerParamDto.getNickname()); String encodePassword = passwordEncoder.encode(password); newUser.setPassword(encodePassword); newUser.setEmail(registerParamDto.getEmail()); newUser.setName("学生"); newUser.setUtype("101001"); newUser.setStatus("1"); newUser.setCreateTime(LocalDateTime.now()); int insert = xcUserMapper.insert(newUser); if (insert < 0) { XuechengPlusException.cast("注册失败"); } // 向用户角色关系表添加数据 XcUserRole xcUserRole = new XcUserRole(); String userRoleId = UUID.randomUUID().toString(); xcUserRole.setId(userRoleId); xcUserRole.setUserId(userId); xcUserRole.setRoleId("17"); xcUserRole.setCreateTime(LocalDateTime.now()); int insert1 = xcUserRoleMapper.insert(xcUserRole); if (insert1 < 0) { XuechengPlusException.cast("注册失败"); } } }
测试,输入手机号,点击发送验证码的时候,redis中也有了验证码

然后点击注册,也进入了断点里

在数据库里也有了注册的数据



浙公网安备 33010602011771号