1 基础
1.1 [1]jwt:3部分
01.组成
a.头部(Header)
包含了关于生成该 JWT 的信息以及所使用的算法类型。
b.载荷(Payload)
a.说明
包含了要传递的数据,例如身份信息和其他附属数据
b.官方规定了7个字段
iss (Issuer):签发者
sub (Subject):主题
aud (Audience):接收者
exp (Expiration time):过期时间
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号
c.签名(Signature)
使用密钥对头部和载荷进行签名,以验证其完整性
02.优势
a.无状态性
传统的基于会话的认证机制需要服务器在会话中存储用户的状态信息,包括用户的登录状态、权限等
而使用 JWT,服务器无需存储任何会话状态信息,所有的认证和授权信息都包含在 JWT 中,使得系统可以更容易地进行水平扩展
b.跨域支持
由于 JWT 包含了完整的认证和授权信息,因此可以轻松地在多个域之间进行传递和使用,实现跨域授权
c.适应微服务架构
在微服务架构中,很多服务是独立部署并且可以横向扩展的,这就需要保证认证和授权的无状态性
使用 JWT 可以满足这种需求,每次请求携带 JWT 即可实现认证和授权
d.自包含
JWT 包含了认证和授权信息,以及其他自定义的声明
这些信息都被编码在 JWT 中,在服务端解码后使用。JWT 的自包含性减少了对服务端资源的依赖,并提供了统一的安全机制
e.扩展性
JWT 可以被扩展和定制,可以按照需求添加自定义的声明和数据,灵活性更高
1.2 [1]jwt:原理
00.汇总
a.三部分
生成JWT
传输JWT
验证JWT
b.JWT的执行流程
用户登录与令牌生成
客户端存储令牌
请求携带令牌
服务端验证令牌
授权与响应
01.三部分
a.生成JWT
在用户登录时,当服务器端验证了用户名和密码的正确性后,会根据用户的信息
如用户 ID 和用户名称,加上服务器端存储的 JWT 秘钥一起来生成一个 JWT 字符串
我们所说的Token,Encoded 编码过的,类似于:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Decoded 解码后会得到三部分内容:头部(Header)+载荷(Payload)+签名(Signature)
b.传输JWT
JWT 通常存储在客户端的 Cookie、LocalStorage、SessionStorage 等位置
客户端在每次请求时把 JWT 放在 Authorization 头中或作为参数传递给服务器端
c.验证JWT
服务器端接收到 JWT 的 Token 后,会先将 Token Decoded 解码,之后会得到头部(Header)+载荷(Payload)+签名(Signature)
然后服务器端会使用它本地存储的秘钥,以及头部(Header)中的加密算法和载荷(Payload)中的信息进行重新加密,得到一个新的签名
最后会判断 Token 的真伪,用上一步新生成的签名和 Decoded 解码得到的签名(Signature)进行判断
如果二者一致,则说明当前的 Token 有效性的、完整的,可以执行后续的操作了,否则则返回 Token 错误
当然在这一步判断时,我们通常也要看载荷(Payload)中的过期时间是否有效,如果无效,则需要提示用户重新登录
02.JWT的执行流程
a.用户登录与令牌生成
用户通过用户名和密码发起登录请求
服务端验证用户凭证,若验证成功,则使用 JWT 工具类生成令牌
-----------------------------------------------------------------------------------------------------
Header:指定算法(如 HS256)和令牌类型(JWT)
Payload:包含用户信息(如用户 ID、角色)和声明(如过期时间 exp)
Signature:使用密钥对 Header 和 Payload 进行签名,确保令牌不可篡改
b.客户端存储令牌
服务端将生成的 JWT 返回给客户端(通常通过响应体或 Header)
客户端(如浏览器或移动端)将令牌存储在本地(如 LocalStorage 或 Cookie)
c.请求携带令牌
客户端在后续请求的 Authorization Header 中以 Bearer 格式携带 JWT
d.服务端验证令牌
拦截器/过滤器:Spring Boot 通过自定义拦截器或 Spring Security 过滤器链拦截请求,提取并验证 JWT
签名验证:使用密钥校验签名是否有效
过期检查:检查 exp 字段是否过期
用户信息提取:解析 Payload 中的用户信息(如用户 ID),用于后续权限控制
e.授权与响应
若验证通过,服务端处理请求并返回数据
若验证失败(如令牌过期或签名错误),返回 401 状态码或自定义错误信息
1.3 [1]jwt:实现
00.汇总
1.引入JWT相关依赖,如jjwt
2.实现一个用于生成和解析JWT的工具类
3.创建一个自定义的AuthenticationFilter,用于从请求头中提取JWT并进行认证
4.在SecurityConfig类中配置HttpSecurity,将自定义的AuthenticationFilter添加到过滤器链
01.依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
02.实现一个用于生成和解析 JWT 的工具类
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
public class JwtUtil {
private static final String SECRET_KEY = "mySecretKey";
private static final long EXPIRATION_TIME = 86400000; // 1 day
// 生成 JWT
public static String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, SECRET_KEY)
.compact();
}
// 解析 JWT
public static Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody();
}
// 从 JWT 中获取用户名
public static String getUsernameFromToken(String token) {
return parseToken(token).getSubject();
}
}
03.创建一个自定义的 AuthenticationFilter,用于从请求头中提取 JWT 并进行认证
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
String token = null;
String username = null;
if (header != null && header.startsWith("Bearer ")) {
token = header.substring(7);
username = JwtUtil.getUsernameFromToken(token);
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
username, null, new ArrayList<>());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
04.在 SecurityConfig 类中配置 HttpSecurity,将自定义的 AuthenticationFilter 添加到过滤器链
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
}
1.4 [1]传统方案:4种
00.总结
HTTP Auth Authentication
Cookie + Session
Token + JWT由三部分组成:Header(头部)、Payload(负载)和Signature(签名)
OAuth
01.HTTP Auth Authentication
a.场景
一般多被用在内部安全性要求不高的的系统上,如路由器网页管理接口
b.优点
通用HTTP身份验证框架有多个验证方案使用。不同的验证方案会在安全强度上有所不同
c.缺点
①请求上携带验证信息,容易被嗅探到
②无法注销
02.Cookie + Session
a.场景
传统系统独立鉴权
b.优点
服务端存储session,客户端存储cookie,其中cookie保存的为sessionID
可以灵活revoke权限,更新信息后可以方便的同步session中相应内容
分布式session,一般使用redis存储
c.缺点
依赖于浏览器
03.Token,JWT由三部分组成:Header(头部)、Payload(负载)和Signature(签名)
a.场景
适合做简单的RESTful API认证
适合一次性验证,例如注册激活链接
b.优点
服务器不再需要存储session,服务器认证鉴权业务可以方便扩展
JWT并不依赖cookie,可以使用header传递
为减少盗用,要使用HTTPS协议传输
c.缺点
①使用过程中无法废弃某个token,有效期内token一直有效
②payload信息更新时,已下发的token无法同步
04.OAuth
a.说明
a.场景
简化模式:不安全,适用于纯静态页面应用
密码模式:一般在内部系统中使用,调用者是以用户为单位
客户端模式:一般在内部系统之间的API调用,两个平台之间调用,以平台为单位
授权码模式:功能最完整、流程最严密的授权模式,通常使用在公网的开放平台中
b.优点
OAuth是一个开放标准,允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息
而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容
c.缺点
相对于场景
b.四种授权模式
a.客户端模式(Client Credentials)
这是最简单的一种模式,我们可以直接向验证服务器请求一个Token(这里可能有些小伙伴对Token的概念不是很熟悉
Token相当于是一个令牌,我们需要在验证服务器**(User Account And Authentication)**服务拿到令牌之后
才能去访问资源,比如用户信息、借阅信息等,这样资源服务器才能知道我们是谁以及是否成功登录了)
当然,这里的前端页面只是一个例子,它还可以是其他任何类型的**客户端**,比如App、小程序甚至是第三方应用的服务
虽然这种模式比较简便,但是已经失去了用户验证的意义,压根就不是给用户校验准备的,而是更适用于服务内部调用的场景
b.密码模式(Resource Owner Password Credentials)
密码模式相比客户端模式,就多了用户名和密码的信息,用户需要提供对应账号的用户名和密码,才能获取到Token
虽然这样看起来比较合理,但是会直接将账号和密码泄露给客户端,需要后台完全信任客户端不会拿账号密码去干其他坏事,所以这也不是我们常见的
c.隐式授权模式(Implicit Grant)
首先用户访问页面时,会重定向到认证服务器,接着认证服务器给用户一个认证页面,等待用户授权,用户填写信息完成授权后,认证服务器返回Token
它适用于没有服务端的第三方应用页面,并且相比前面一种形式,验证都是在验证服务器进行的,敏感信息不会轻易泄露,但是Token依然存在泄露的风险
d.授权码模式(Authrization Code)
这种模式是最安全的一种模式,也是推荐使用的一种,比如我们手机上的很多App都是使用的这种模式
相比隐式授权模式,它并不会直接返回Token,而是返回授权码,真正的Token是通过应用服务器访问验证服务器获得的
在一开始的时候,应用服务器(客户端通过访问自己的应用服务器来进而访问其他服务)和验证服务器之间会共享一个`secret`
这个东西没有其他人知道,而验证服务器在用户验证完成之后,会返回一个授权码
应用服务器最后将授权码和`secret`一起交给验证服务器进行验证,并且Token也是在服务端之间传递,不会直接给到客户端
这样就算有人中途窃取了授权码,也毫无意义,因为,Token的获取必须同时携带授权码和secret
但是`secret`第三方是无法得知的,并且Token不会直接丢给客户端,大大减少了泄露的风险
c.SpringSecurity与OAuth的关系
a.SpringSecurity
定义:Spring Security 是一个强大的、安全的认证和访问控制框架,是 Spring 框架的一个子项目
功能:提供全面的安全服务,包括认证、授权、常见攻击防护(如 CSRF、Session Fixation、Clickjacking)等
特点:高度可配置,支持多种认证机制,如表单登录、HTTP Basic、LDAP、JWT、OAuth 等
b.OAuth
定义:OAuth(Open Authorization)是一种开放标准,用于令牌授权(Token Authorization),允许第三方应用程序以有限的权限访问用户资源
版本:有 OAuth 1.0 和 OAuth 2.0 两个主要版本,OAuth 2.0 是目前广泛使用的版本。
特点:OAuth 2.0 定义了一种获取和使用访问令牌(Access Token)的方式,主要用于授权而非认证。常用于第三方应用程序的授权,如允许一个应用访问用户的社交媒体账户数据
c.SpringSecurity 与 OAuth 的关系
Spring Security 并不是 OAuth,但它可以与 OAuth 一起使用
特别是在使用 OAuth 2.0 进行授权时,Spring Security 提供了相关的支持库,称为 Spring Security OAuth
d.Spring Security OAuth
定义:Spring Security OAuth 是 Spring Security 的一个扩展模块,专门用于支持 OAuth 2.0
功能:作为 OAuth 2.0 认证服务器(Authorization Server)实现,管理客户端认证、用户授权等
作为 OAuth 2.0 资源服务器(Resource Server)实现,保护 API 资源,仅允许持有有效访问令牌的请求访问
使用场景:常用于保护 RESTful API、实现单点登录(SSO)、与第三方服务集成等
1.5 [1]token方案:2种
00.总结
a.回答
前后端分离后,跨域导致【sessionid丢失,cookies无法写入】
只有 cookie+session、Token 这2种方式,使用redis与否,取决于系统是否是分布式
b.说明
Redis实现分布式session管理,这种就是采用 cookie+session 的方式
JWT+Redis,这种则是采用 Token 的方式,这里redis可以取代后端session域的功能,但此时已经不与cookie进行交互
01.方式一
a.搭配
Cookie+Session:前后端不分离;在前后端分离的架构中,使用Session管理用户登录状态是不合适的
Cookie+纯Redis(Redis管理Session):分布式系统
b.是否使用Redis?
是否为分布式,分布式多台机器需要共用一个session,如果需要跨服务器,redis可以单独作为服务来管理session
b.实现原理
用户登录时,服务器生成一个唯一的Session ID,将会话数据(如用户信息)存储在Redis中,关联这个Session ID
服务器通过Set-Cookie头部将Session ID发送给客户端
每次请求时,客户端通过Cookie携带Session ID,服务器根据Session ID从Redis中检索会话信息
02.方式二
a.搭配
纯JWT:单机或需要简化架构
JWT+Redis:分布式、高并发或需要灵活会话管理
b.是否使用Redis?
是否为分布式,分布式多台机器需要共用一个session,如果需要跨服务器,redis可以单独作为服务来管理session
c.流程:
用户登录时,服务器生成JWT,并将详细会话信息存储在Redis中,关联一个唯一的会话ID(例如JWT中的jti字段)
JWT发送给客户端,客户端将其存储在Cookie或Local Storage中
每次请求时,客户端发送JWT,服务器验证JWT的有效性
服务器根据JWT中的会话ID从Redis检索详细会话数据
1.6 [1]登录状态:3种
01.Cookie
前端发起登录请求,将用户名密码传给服务端,服务端在数据库查询进行用户名密码的认证
如果认证成功,会响应用户名到前端,前端把用户名保存到cookie
cookie保存在浏览器这一端,登录成功之后的所有请求都会自动带上这个cookie,这样就维持了用户的登录状态
02.Session
前端发起登录请求,将用户名密码传给服务端,服务端在数据库查询进行用户名密码的认证
如果认证成功,就可以往session当中存入当前的用户信息,然后进行响应
会在响应头里存入一个set-cookie的属性,然后再把当前session的唯一ID放在属性当中
前端会自动在cookie当中存入当前的session ID,登录成功之后,下一次的请求就会自动在请求头当中设置cookie的信息
服务端拿到cookie中的session ID就可以得到这一次的请求所对应的session信息了
那么就可以获取到当前登录的用户信息,以维持当前登录状态
03.JWT
前端发起登录请求,将用户名密码传给服务端,服务端在数据库查询进行用户名密码的认证
如果认证成功,服务端会生成一个JWT token(包含一些用户的基本非敏感信息,用户ID和用户名等)并返回给前端
前端保存这个token,在后续请求接口时在header中添加Authorization字段,并且把token放在该字段的value中
服务端通过header获取token之后,校验token的签名是否有效,若有效且token没有过期,则通过payload获取用户信息
以维持当前登录状态

1.7 [1]认证、授权、鉴权、权限控制
00.定义
a.流程
场景1:使用门禁卡开门,认证、授权、鉴权、权限控制四个环节一气呵成,在瞬间同时发生
场景2:用户的网站登录,认证和授权(前提,用户登录),鉴权和权限控制(后续,支付商品)
误区:很多时候认证、授权、鉴权和权限控制一同发生,以至于被误解为,认证就是鉴权,鉴权就是认证
身份证 授信媒介 门禁卡识别器 控制门的开关
用户名和密码 session session的合法性 接口允许或拒绝请求
认证 ---------------> 授权 ---------------> 鉴权 ---------------> 权限+控制 ----------------> 操作
用户手机 cookie cookie的合法性 接口允许或拒绝请求
电子邮箱 token token的合法性 接口允许或拒绝请求
b.认证(identification)
定义:验证用户身份的过程,涉及到用户名和密码的检查
实现:根据声明者独特的识别信息
表现:成功认证后,用户将获得一个认证令牌,用于后续的授权过程
c.授权(authorization)
定义:确定用户是否具有访问特定资源或执行特定操作的权限
实现:颁发一个授信媒介,不可被篡改,不可伪造,受保护
表现:系统基于用户的角色、权限或访问控制列表(ACL)来判断用户是否具有访问权限
d.鉴权(authentication)
定义:鉴别(抽象的逻辑概念,定义和配置可执行的操作),控制(具体的实现方式,通过一定方式控制操作允许和禁止)
实现:鉴权和授权是一一对应关系,解析授信媒介,确认其合法性、有效性
e.权限控制(access/permission control)
定义:权限(抽象的逻辑概念,定义和配置可执行的操作),控制(具体的实现方式,通过一定方式控制操作允许和禁止)
实现:实现方式多样,根据具体情况来实现
01.认证(identification)
a.定义
认证:根据声明者所特有的识别信息,确认声明者的身份
防伪:为了确认用户的身份,防止伪造,在安全要求高的场合,经常会使用多个认证方式对用户的身份进行校验
b.实现
身份证
用户名和密码
用户手机:手机短信、手机二维码扫描、手势密码
用户的电子邮箱
基于时间序列和用户相关的一次性口令
用户的生物学特征:指纹、语音、眼睛虹膜
用户的大数据识别
02.授权(authorization)
a.定义
授权:资源所有者委派执行者,赋予执行者指定范围的资源操作权限,以便执行者代理执行对资源的相关操作
资源所有者:拥有资源的所有权利,一般就是资源的拥有者
资源执行者:被委派去执行资源的相关操作
操作权限:可以对资源进行的某种操作
资源:有价值的信息或数据等,受到安全保护
b.实现
浏览器的session机制:一个访问会话保持着用户的授权信息
浏览器的cookie机制:一个网站的cookie保持着用户的授权信息
颁发授权token令牌:一个合法有效的令牌中保持着用户的授权信息
03.鉴权(authentication)
a.定义
鉴权:对于一个声明者所声明的身份权利,对其所声明的真实性进行鉴别确认的过程
鉴权主要是对声明者所声明的真实性进行校验。若从授权出发,则会更加容易理解鉴权
授权和鉴权是两个上下游相匹配的关系,先授权,后鉴权
授权和鉴权两个词中的“权”,是同一个概念,就是所委派的权利,在实现上即为授信媒介的表达形式
b.实现
门禁卡:通过门禁卡识别器
钥匙:通过相匹配的锁
银行卡:通过银行卡识别器
session/cookie/token:校验session/cookie/token的合法性和有效性
c.理解
鉴权是一个承上启下的一个环节,上游它接受授权的输出,校验其真实性后,然后获取权限,为下一步的权限控制做准备
04.权限控制(access/permission control)
a.定义
权限控制:对可执行的各种操作组合配置为权限列表,然后根据执行者权限,若其操作在权限范围内,则允许执行
b.实现
门禁:控制门的开关
自行车锁:控制车轮
互联网web后端服务:控制接口访问,允许或拒绝访问请求
c.理解1
权限控制分为两部分进行理解,一个是权限,另一个是控制。权限是抽象的逻辑概念,而控制是具体的实现方式
权限:鉴权的输出是权限(Permission),一旦有了权限,便知道了可执行的操作,接下来就是控制的事情了
控制:根据执行者的权限,对其所执行的操作进行判断,决定允许或禁止当前操作的执行
d.理解2
权限作为一个抽象的概念,将执行者和可具体执行操作相分离。若以门禁卡的权限实现为例,则可以各自表达为如下内容:
这是一个门禁卡,拥有开公司所有的门的权限
这是一个门禁卡,拥有管理员角色的权限,因而可以开公司所有的门
1.8 [2]cookie、session
01.总结
Cookie:用于存储小量数据,存储在客户端,适合保存用户偏好设置、身份验证等信息
Session:用于存储更复杂的数据,存储在服务器,适合保存用户的会话信息和敏感数据
通常情况下,Session 被认为比 Cookie 更安全,因为 Session 数据不暴露给客户端
02.联系
存储目的:Session 和 Cookie 都用于在用户与服务器之间保持状态,跟踪用户会话
配合使用:通常,Session ID 会存储在 Cookie 中,服务器根据这个 Session ID 来访问对应的 Session 数据
03.区别
特性 Cookie Session
存储位置 存储在客户端(浏览器) 存储在服务器上
数据大小限制 每个 Cookie 大小一般限制在 4KB 左右 无严格限制,受服务器内存和设置影响
过期时间 可以设置过期时间,超过后自动删除 通常在浏览器关闭后或超时后失效
安全性 不安全,数据在客户端可见 相对安全,数据保存在服务器上
数据共享 可跨不同页面和会话共享 只能在同一会话中共享
04.Cookie
a.定义
Cookie是服务器发送到用户浏览器并保存在本地的一小块数据
b.作用
用于在客户端存储用户的会话信息或状态
每次浏览器向服务器发送请求时,会自动携带Cookie,从而实现身份验证或状态保持
c.特点
存储在客户端(浏览器)
有大小限制(通常为4KB)
可以设置过期时间,分为会话Cookie(关闭浏览器后失效)和持久Cookie(根据过期时间失效)
d.应用场景
保存用户登录状态
记录用户的偏好设置(如语言、主题)
05.Session
a.定义
Session是服务器端存储用户会话信息的一种机制
b.作用
用于在服务器端保存用户的会话数据,通常与Cookie配合使用
服务器会为每个用户创建一个唯一的Session ID,并通过Cookie传递给客户端
c.特点
存储在服务器端,安全性较高
依赖于Cookie来传递Session ID
会话结束后(如用户关闭浏览器),Session数据可能会被清除(取决于服务器配置)
d.应用场景
保存用户的登录状态
存储用户的临时数据(如购物车信息)
1.9 [2]cookie、session、jwt
00.回答
a.答案
Token是替代Cookie的方式,JWT是一种开放标准
b.总结
cookie: 浏览器自动保存和发送,用于记住用户的登录状态,但存在被猜测和篡改的风险
session: 通过在服务器端保存用户数据,并仅向客户端返回一个唯一标识符(Session ID),以减少客户端存储的数据量
token: 在非浏览器环境下替代cookie的一种方式,客户端自行维护身份验证信息
JWT (JSON Web Token): 一种开放标准,定义了一种紧凑且自包含的方式用于安全地在各方之间传输信息,适用于微服务架构和跨域数据交换
c.图示
特性 Cookie Session JWT
存储位置 客户端 服务器 客户端
安全性 较低 较高 取决于存储方式
服务器压力 无 高 无
适用场景 轻量级存储 需要存储用户状态 无状态认证、微服务
是否支持跨域 受限 受限 支持
是否可篡改 易被篡改 不易篡改 签名验证防篡改
适合单点登录(SSO) 否 否 是
01.存储位置
a.Cookie
数据存储在客户端(浏览器),每次请求时都会自动携带到服务器
b.Session
数据存储在服务器,客户端通过 sessionId 访问服务器上的数据
c.JWT
数据存储在客户端(一般保存在 localStorage、sessionStorage 或 cookie),服务器只负责验证,不存储用户状态
02.安全性
a.Cookie
如果未设置 HttpOnly,JavaScript 可以访问,容易受到 XSS(跨站脚本攻击)
如果未设置 Secure,在 HTTP 连接中可能被劫持(中间人攻击)
b.Session
服务器存储 Session 数据,较 Cookie 更安全
需要 Session 机制配合 cookie 或 token 进行身份识别
c.JWT
采用签名(HMAC 或 RSA)保证数据完整性,但不能防止被窃取
一旦被拦截,由于 JWT 本身包含所有信息,可能导致更严重的数据泄露问题
03.数据存储
a.Cookie
一般存储少量数据(4KB 限制)
b.Session
存储在服务器,理论上可以存储大量数据,但会占用服务器资源
c.JWT
存储在客户端,大小一般受限于 localStorage 或 cookie,但通常比 Cookie 能存储更多信息
04.适用场景
a.Cookie
适用于存储少量的、对安全性要求不高的数据,如用户偏好设置
可用于轻量级的身份认证(如 remember me 功能)
b.Session
适用于需要服务器存储用户信息的场景,如购物车、用户登录状态管理
适用于需要更高安全性的应用
c.JWT
适用于无状态认证(Stateless Authentication),如单点登录(SSO)
适用于微服务架构,因为不需要服务器存储用户状态信息
05.是否支持跨域
a.Cookie
默认不支持跨域访问,但可以通过 SameSite=None 和 CORS 配置来实现跨域
b.Session
依赖 Cookie 机制,默认不能跨域
c.JWT
可以在多个服务器或前后端分离的架构中使用,前提是正确配置 CORS
06.是否支持无状态
a.Cookie
有状态(由服务器验证)
b.Session
有状态(需要服务器存储)
c.JWT
无状态(无需服务器存储,只需要验证签名)
07.注意事项
a.使用 Cookie 时需要注意
a.安全配置
设置 HttpOnly,防止 JavaScript 访问,避免 XSS 攻击
设置 Secure,确保 Cookie 仅在 HTTPS 连接下传输,防止中间人攻击
使用 SameSite 限制 Cookie 发送行为,防止 CSRF 攻击
Strict:不允许跨站请求携带 Cookie(最安全)
Lax(默认):允许部分跨站请求(如 GET)
None(需要 Secure):允许所有跨站请求,适用于跨域场景
b.存储敏感信息
避免存储用户密码等敏感信息,建议只存储 token 或 sessionId
若存储 JWT,建议设置较短的 httpOnly 失效时间,并配合 refresh token
c.Cookie 大小限制
单个 Cookie 最大 4KB,存储过多数据会导致请求变大,影响性能
d.Cookie 过期时间
使用 Expires 或 Max-Age 控制过期时间
Session Cookie(不设置过期时间):浏览器关闭时删除。
Persistent Cookie(设置 Max-Age):可持久化存储。
e.跨域问题
通过 Access-Control-Allow-Credentials: true 允许跨域携带 Cookie,但要确保 CORS 服务器正确配置
b.使用 Session 时需要注意
a.存储方式
默认存储在服务器内存中,适用于小规模应用,但大规模应用建议使用 Redis 或 数据库 存储,提高扩展性
如果 Session 过多,会占用服务器资源,影响性能
b.Session ID 传输
通常通过 Cookie 传递 sessionId,但也可以通过 URL 传递(不推荐,容易被劫持)
使用 Secure 和 HttpOnly 保护存储 sessionId 的 Cookie,防止 XSS 和劫持攻击
c.Session 过期策略
设定合理的过期时间,避免长期占用服务器资源
短期会话:如 30 分钟内无操作自动销毁
长期登录:可以存储 Remember Me 机制,让用户保持登录状态
d.分布式应用
在多服务器环境下,Session 需要做 共享存储,避免用户在不同服务器登录时丢失 Session
方案:
Redis/Memcached:将 Session 存储到缓存,提高查询效率
数据库:适用于持久化存储 Session,但查询性能不如缓存
e.Session 固定攻击(Session Fixation)
避免用户在认证前后使用相同的 Session ID,建议
在登录成功后,重新生成 Session ID
结合 IP 地址、User-Agent 进行身份校验,防止 Session 被盗用
c.使用 JWT 时需要注意
a.避免 JWT 过长
JWT 由 Header.Payload.Signature 组成,存储过多数据会导致请求体积变大,影响性能
建议只存储必要信息(如用户 ID、角色权限),避免存储敏感数据
b.Token 过期策略
JWT 一旦生成,服务器无法主动销毁(除非存储黑名单)
解决方案:
短期 Token(Access Token) :设置较短过期时间(如 15~30 分钟)
长期 Token(Refresh Token) :存储在数据库,用于刷新 Access Token,降低频繁登录的用户体验问题
c.存储位置
Cookie(推荐) :搭配 HttpOnly 和 Secure 提高安全性
localStorage / sessionStorage(不推荐) :容易被 XSS 攻击获取,增加被盗风险
d.避免 Token 泄露
一旦 Token 被窃取,攻击者可随意使用,因此:
使用 HTTPS 传输 Token,防止中间人攻击
不在 URL 传递 Token(容易被日志或浏览器缓存暴露)
可结合 指纹识别(如 IP 绑定、设备 ID) 限制 Token 使用范围
e.Token 失效处理
JWT 无法主动撤销,因此可以:
黑名单机制:存储废弃 Token 列表,但需要占用服务器资源
数据库存储 Token:在用户登出时,使数据库中该 Token 失效
f.签名算法选择
避免使用对称加密(如 HS256),尽量使用 非对称加密(如 RS256) ,提高安全性
HS256(HMAC) :密钥泄露后攻击者可伪造 Token
RS256(RSA) :公钥验证,私钥签名,更安全
g.防止重放攻击
使用 jti(JWT ID) 或 nonce(唯一随机数)确保 Token 不能被重复使用
结合时间戳 iat 和 exp,拒绝过期或重复请求
1.10 [2]cookies、loaclStorage、sessionStorage
00.总结
a.相同点
都是存储数据,存储在web端,并且都是同源
b.不同点
a.存储大小不同
cookie非常小,它的大小限制为4KB左右,主要用于保存用户信息,而且每一次请求都会带上cookie,体验非常不好
session和local直接存储在本地,请求不会携带,并且容量比cookie要大的多,可以达到5M或更大
b.数据有效期不同
cookie:只在设置的cookie过期时间之前有效,即使窗口关闭或浏览器关闭
session:仅在当前浏览器窗口关闭之前有效
local:始终有效,窗口或浏览器关闭也一直保存,因此用作持久数据
c.作用域不同
cookie和local都可以支持多窗口共享,而session不支持多窗口共享但是都支持a链接跳转的新窗口
c.为什么Cookie小,localStorage大?
a.性能考量
由于Cookie会随每个HTTP请求发送,如果容量过大,会显著增加网络流量,影响性能
因此,Cookie的设计初衷是为了存储关键、少量的信息
b.历史与兼容性
Cookie技术出现较早,为了适应早期网络环境,其设计较为保守
而localStorage作为HTML5的一部分,从设计之初就考虑到了现代网络的需求和挑战
01.Cookie:HTTP的忠实伴侣
a.定义
Cookie是服务器在本地终端(通常是浏览器)上存储的小段数据
由服务器生成并发送给User-Agent(如浏览器),浏览器会将Cookie的键值对存储在某个目录下的文本文件中
在后续请求中,浏览器会自动将Cookie发送给服务器
b.用途
身份认证:用于保持用户登录状态,避免用户每次访问网站都需重新登录
个性化设置:存储用户的偏好设置,如语言选择、主题风格等,提供个性化的用户体验
c.特点
大小限制:每个域名的Cookie总大小有限制,一般不超过4KB
自动携带:每次HTTP请求都会自动携带相关域名的Cookie,可能增加网络传输负担
安全性:可以通过设置HttpOnly属性来防止JavaScript脚本读取Cookie,提高安全性
d.示例
const cookieHandler = new CookieManager();
cookieHandler.setCookie('username','omg',30)
class CookieManager {
constructor() { }
setCookie(name, value, expires) {
let expiresDate = '';
if (expires) {
const date = new Date(); // 当前时间
date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000)
expiresDate = `;expires=${date.toUTCString()}`;
}
document.cookie = name + "=" + value + expiresDate + ";path=/"
}
deleteCookie(name) {
this.setCookie(name,"",-1)
}
}
02.localStorage:现代浏览器的宠儿
a.定义
localStorage是HTML5引入的一种本地存储方式,允许在用户浏览器中存储更大的数据量(通常为5MB左右)
数据是持久的,除非用户主动清除或程序删除
b.用途
离线存储:存储用户数据,如购物车商品、未完成的表单数据,以便用户在离线状态下仍能访问
缓存数据:缓存静态资源或动态生成的数据,减少服务器请求,提高应用性能
c.特点
大容量:相比Cookie,localStorage提供的存储空间更大,更适合存储较大体积的数据
API友好:使用JavaScript API进行数据的读写,如setItem、getItem等,操作简单直观
不随请求发送:localStorage中的数据不会自动随HTTP请求发送给服务器,减轻网络传输负担
d.API使用
存储数据:localStorage.setItem('key', 'value');
读取数据:let value = localStorage.getItem('key');
删除数据:localStorage.removeItem('key');
清空所有数据:localStorage.clear();
e.示例
localStorage.setItem('key', 'value');
let value = localStorage.getItem('key');
console.log(value); // 输出:value
03.sessionStorage
a.特点1
在浏览器会话期间有效
关闭浏览器标签页/窗口后会清除
不同浏览器窗口/标签页之间不共享
b.特性2
✓ 关闭浏览器后会过期
✓ 打开新标签页需要重新验证
× 同一个浏览器会话中不会过期
c.API使用
sessionStorage.setItem(key, value):用于存储数据。key是数据的键,value是数据的值
sessionStorage.getItem(key):用于检索数据。返回存储在指定键的数据
sessionStorage.removeItem(key):用于删除指定键的数据
sessionStorage.clear():用于清除所有存储的数据
d.示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SessionStorage Example</title>
<script>
// 存储数据到 sessionStorage
function saveData() {
const inputData = document.getElementById('inputData').value;
sessionStorage.setItem('myData', inputData);
alert('Data saved to sessionStorage!');
}
// 从 sessionStorage 检索数据
function loadData() {
const storedData = sessionStorage.getItem('myData');
if (storedData) {
alert('Retrieved data from sessionStorage: ' + storedData);
} else {
alert('No data found in sessionStorage.');
}
}
// 清除 sessionStorage 中的数据
function clearData() {
sessionStorage.removeItem('myData');
alert('Data cleared from sessionStorage!');
}
</script>
</head>
<body>
<h1>SessionStorage Example</h1>
<input type="text" id="inputData" placeholder="Enter some data">
<button onclick="saveData()">Save Data</button>
<button onclick="loadData()">Load Data</button>
<button onclick="clearData()">Clear Data</button>
</body>
</html>
1.11 [3]redis实现分布式session会话
01.为什么登录使用【redis实现分布式session会话】的方案?
a.回答
前后端分离后,跨域导致【sessionid丢失,cookies无法写入】
b.说明
在前后端分离的架构中,使用Session管理用户登录状态是不合适的
原因是前端和后端是通过API通信的,通常会涉及跨域问题,而Session依赖于服务端的状态保持,增加了管理复杂性
跨域设置Cookie需要额外的CORS配置,并且在分布式环境中,Session数据共享也会带来额外的开销
相比之下,使用JWT进行认证更合适,JWT是一种无状态的令牌,可以通过HTTP头发送,避免了跨域Cookie的问题,并且在负载均衡和扩展性方面也更具优势
c.注意
这里使用redis存储session信息,一定是基于分布式环境
1.多节点一致性:在分布式系统中,使用Redis可以确保不同服务器节点之间的【会话数据一致性】,避免了会话粘滞性的问题
2.高性能:Redis是一个内存数据库,具有极快的读写速度,能够快速响应会话相关操作,提升用户体验
3.扩展性和高可用性:Redis支持水平扩展和高可用性配置,可以通过增加节点来应对更大的并发访问量,并且在出现故障时能够自动切换,保证系统的稳定运行
4.数据持久化:虽然Redis是内存数据库,但它也提供了数据持久化机制,可以防止数据丢失并快速恢复系统
5.安全性:Redis支持密码认证和TLS加密,确保会话数据的安全性
1.12 [3]使用jwt而非cookie+session
01.为什么使用JWT,而不是Cookie和Session
a.回答
前后端分离后,跨域导致【sessionid丢失,cookies无法写入】
b.说明
在前后端场景下,使用JWT比传统的Cookie和Session机制更合适
JWT是无状态的,服务器不需要存储会话数据,默认支持跨域访问
首先,JWT是无状态的,服务器不需要存储会话数据,这在分布式系统和微服务架构中非常有用,可以减少服务器的负担
其次,JWT通过HTTP头传递,支持跨域访问,适合前后端分离的应用,避免了跨域Cookie设置的复杂性
此外,JWT具有灵活性和扩展性,可以包含丰富的用户信息和权限声明,适用于移动应用和单页应用
最后,JWT使用签名来确保令牌的完整性和真实性,增强了安全性
因此,在需要无状态认证、跨域支持和高扩展性的场景中,JWT是一个更好的选择
c.Session在多系统中的缺陷
一般情况下,用户的登录状态是记录在 Session 中的,要实现共享登录状态,就要先共享 Session
但是由于不同的应用系统有着不同的域名,尽管 Session 共享了
但是由于 SessionId 是往往保存在浏览器 Cookie 中的,因此存在作用域的限制
无法跨域名传递,也就是说当用户在 a.com 中登录后
Session Id 仅在浏览器访问 a.com 时才会自动在请求头中携带
而当浏览器访问 b.com 时,Session Id 是不会被带过去的
1.13 [3]浏览网站为啥老要接受cookie
01.浏览网站为啥老要接受cookie
a.原有
这里假设我网站的域名是example.com,我给Domain设置的是.example.com
那么我所有example.com子域名下面的网站都是能共享Cookie的
比如a.example.com和b.example.com,这种一般用于很多个项目单点登陆,避免用户在类似的网站上重复登陆
b.第一方、第三方
如果我网站的域名是example.com,但是我给Domain设置的是其他的网址
那这里面可操作的空间就很大了,如果是我们浏览的网站自己跟踪存储的Cookie
我们一般叫做第一方Cookie,这个其实还算正常
-----------------------------------------------------------------------------------------------------
但是,如果存储的Cookie可访问地址不是我们当前访问的网站,而是别的什么网站,这里就需要特别留意了
是不是把我们的浏览记录提供给友商了,这种Cookie就叫做:第三方Cookie
1.14 [3]浏览器内多个标签页之间的通信
01.localstorage
使用localStorage.setItem(key,value),添加内容;
使用storage事件监听添加、修改、删除的动作;
window.addEventListener("storage",function(event){
$("#name").val(event.key+”=”+event.newValue);
});
02.cookie+setInterval
a.html
<input id="name"><input type="button" id="btnOK"value="发送">
b.js代码-页面1
$(function(){
$("#btnOK").click(function(){
varname=$("#name").val();
document.cookie="name="+name;
});
});
c.js代码-页面2
//获取Cookie天的内容
function getKey(key) {
return JSON.parse("{\""+ document.cookie.replace(/;\s+/gim,"\",\"").replace(/=/gim, "\":\"") +"\"}")[key];
}
//每隔1秒获取Cookie的内容
setInterval(function(){
console.log(getKey("name"));
},1000);
2 单点登录:SSO
2.1 [1]定义
01.单点登录
a.说明
单点登录有个简称是sso,它是一个功能可以控制多个有联系的系统操作
简单地理解为通过单点登录可以让用户只需要登录一次软件或者系统
那么同系统下的平台都可以免去再次注册、验证、访问权限的麻烦程序,通俗易懂的理解为一次性登录也可以一次性下线
b.特点
单点登录(SSO)允许用户在多个应用之间共享身份信息,提高用户体验和安全性
单点登录(SSO)允许用户在多个独立的系统或应用程序中只需登录一次,即可访问所有相关系统或应用程序
单点登录(SSO)是一种用户认证中心的概念,用于管理用户的身份信息,让用户在不同的子系统中无缝使用,允许用户在多个应用之间共享身份信息,提高用户体验和安全性
c.实现
OAuth2
session + cookie
token + refreshToken + 无感刷新
02.详细说明
a.一个系统登录流程
用户进入系统——未登录——跳转登录界面——用户名和密码发送——服务器端验证后
设置一个cookie发送到浏览器,设置一个session存放在服务器——用户再次请求(带上cookie)
服务器验证cookie和session匹配后,就可以进行业务了
b.多个系统登录
如果一个大公司有很多系统,a.seafile.com, b.seafile.com,c.seafile.com
这些系统都需要登录,如果用户在不同系统间登录需要多次输入密码,用户体验很不好
所以使用 SSO (single sign on) 单点登录实现
c.相同域名,不同子域名下的单点登录
在浏览器端,根据同源策略,不同子域名的cookie不能共享
所以设置SSO的域名为根域名。SSO登录验证后,子域名可以访问根域名的 cookie,即可完成校验
在服务器端,可以设置多个子域名session共享(Spring-session)
d.不同域名下的单点登录
CAS流程:用户登录子系统时未登录,跳转到 SSO 登录界面,成功登录后,SSO 生成一个 ST (service ticket )
用户登录不同的域名时,都会跳转到 SSO,然后 SSO 带着 ST 返回到不同的子域名
子域名中发出请求验证 ST 的正确性(防止篡改请求)。验证通过后即可完成不同的业务
2.2 [1]流程:7步
01.实现流程1
a.用户访问
用户尝试访问受保护的资源或应用程序A
b.重定向到SSO服务器
如果用户未登录,应用程序A将用户重定向到SSO认证服务器
c.用户认证
用户在SSO服务器上输入用户名和密码进行身份验证
SSO服务器验证用户身份信息(可以通过数据库、LDAP等)
d.生成认证令牌
身份验证成功后,SSO服务器生成一个认证令牌(Token),通常是JWT或Session ID
e.重定向回应用程序
SSO服务器将用户重定向回应用程序A,并附带认证令牌
f.验证令牌
应用程序A接收到令牌后,向SSO服务器验证令牌的有效性
验证成功后,应用程序A为用户创建会话,允许用户访问资源
g.访问其他应用程序
当用户尝试访问其他应用程序B时,如果应用程序B检测到用户未登录,同样会将用户重定向到SSO服务器
SSO服务器检测到用户已经登录,会直接生成新的认证令牌并重定向回应用程序B
应用程序B验证令牌并创建会话,允许用户访问资源
02.实现流程2
a.用户认证
用户首先访问一个系统,输入用户名和密码进行登录
登录请求被发送到专门的认证中心(Authentication Server)
认证中心验证用户的身份信息,如果验证成功,则生成一个安全令牌(如 JWT、Ticket 等)
b.令牌发放与传递
认证中心将令牌返回给用户首次登录的应用系统
应用系统将令牌存储在用户的本地会话(如浏览器的 Cookie)中
当用户访问其他需要 SSO 支持的应用系统时,浏览器会携带令牌自动发送给目标系统
c.令牌验证与授权
目标系统接收到请求后,发现携带了令牌,则将令牌发送给认证中心进行验证
认证中心验证令牌的有效性(包括签名、有效期等)
如果令牌有效,认证中心会返回一个确认信息给目标系统,证明用户已通过认证
d.资源共享与授权
目标系统接收到认证中心的确认后,允许用户访问系统资源,而无需再次登录
目标系统可以依据令牌中的信息进行权限控制和角色映射
e.会话管理
为了保证安全性,一般会设置令牌的有效期,过了有效期后需要重新认证
在某些实现中,当用户在一个子系统中注销时,会通知认证中心撤销所有关联令牌,从而实现全局注销,保证了其他系统也无法继续使用过期的认证信息
2.3 [2]传统方案:3种
01.说明
a.关键
如何让 Session Id(或 Token)在多个域中共享
b.三种
1.父域Cookie
2.认证中心
3.LocalStorage跨域
02.设计方案
a.方案1:父域 Cookie
a.步骤1
Cookie 的作用域由 domain 属性和 path 属性共同决定。domain 属性的有效值为当前域或其父域的域名/IP地址
在 Tomcat 中,domain 属性默认为当前域的域名/IP地址。path 属性的有效值是以“/”开头的路径
在 Tomcat 中,path 属性默认为当前 Web 应用的上下文路径
b.步骤2
如果将 Cookie 的 domain 属性设置为当前域的父域,那么就认为它是父域 Cookie。Cookie 有一个特点
即父域中的 Cookie 被子域所共享,也就是说,子域会自动继承父域中的 Cookie
c.步骤3
利用 Cookie 的这个特点,可以将 Session Id(或 Token)保存到父域中就可以了
我们只需要将 Cookie 的 domain 属性设置为父域的域名(主域名)
同时将 Cookie 的 path 属性设置为根路径,这样所有的子域应用就都可以访问到这个 Cookie 了
不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.com 和 map.baidu.com
它们都建立在 baidu.com 这个主域名之下,那么它们就可以通过这种方式来实现单点登录
d.总结
此种实现方式比较简单,但不支持跨主域名
b.方案2:认证中心
a.步骤1
我们可以部署一个认证中心,认证中心就是一个专门负责处理登录请求的独立的 Web 服务
b.步骤2
用户统一在认证中心进行登录,登录成功后,认证中心记录用户的登录状态,并将 Token 写入 Cookie
(注意这个 Cookie 是认证中心的,应用系统是访问不到的)
c.步骤3
应用系统检查当前请求有没有 Token,如果没有,说明用户在当前系统中尚未登录,那么就将页面跳转至认证中心进行登录
由于这个操作会将认证中心的 Cookie 自动带过去,因此,认证中心能够根据 Cookie 知道用户是否已经登录过了
如果认证中心发现用户尚未登录,则返回登录页面,等待用户登录,如果发现用户已经登录过了,就不会让用户再次登录了
而是会跳转回目标 URL ,并在跳转前生成一个 Token,拼接在目标 URL 的后面,回传给目标应用系统
d.步骤4
应用系统拿到 Token 之后,还需要向认证中心确认下 Token 的合法性,防止用户伪造
确认无误后,应用系统记录用户的登录状态,并将 Token 写入 Cookie,然后给本次访问放行
(这个 Cookie 是当前应用系统的,其他应用系统是访问不到的)当用户再次访问当前应用系统时
就会自动带上这个 Token,应用系统验证 Token 发现用户已登录,于是就不会有认证中心什么事了。
e.总结
此种实现方式相对复杂,支持跨域,扩展性好,是单点登录的标准做法。
c.方案3:LocalStorage跨域
a.步骤1
单点登录的关键在于,如何让 Session Id(或 Token)在多个域中共享
但是 Cookie 是不支持跨主域名的,而且浏览器对 Cookie 的跨域限制越来越严格
b.步骤2
在前后端分离的情况下,完全可以不使用 Cookie
我们可以选择将 Session Id (或 Token )保存到浏览器的 LocalStorage 中
让前端在每次向后端发送请求时,主动将 LocalStorage 的数据传递给服务端
这些都是由前端来控制的,后端需要做的仅仅是在用户登录成功后
将 Session Id (或 Token )放在响应体中传递给前端
c.步骤3
在这样的场景下,单点登录完全可以在前端实现
前端拿到 Session Id (或 Token )后,除了将它写入自己的 LocalStorage 中之外
还可以通过特殊手段将它写入多个其他域下的 LocalStorage 中
d.总结
此种实现方式完全由前端控制,几乎不需要后端参与,同样支持跨域
03.SSO单点登录-退出
a.说明
目前我们已经完成了单点登录,在同一套认证中心的管理下,多个产品可以共享登录态
现在我们需要考虑退出了,即:在一个产品中退出了登录,怎么让其他的产品也都退出登录?
b.原理
原理其实不难,可以在每一个产品在向认证中心验证 ticket(token) 时
其实可以顺带将自己的退出登录 api 发送到认证中心
c.应用
当某个产品 c.com 退出登录时:
清空 c.com 中的登录态 Cookie。请求认证中心 sso.com 中的退出 api
认证中心遍历下发过 ticket(token) 的所有产品,并调用对应的退出 api,完成退出

2.4 [2]真实实战:2种
00.实现方式
a.共享身份验证
多个系统共享一个身份验证系统,用户只需要在一个系统中进行身份验证,就可以访问所有系统
这种方式需要建立一个共享的身份验证系统,这样可以保证用户信息的安全性
b.代理身份验证
一个系统代表其他系统进行身份验证,用户在登录时输入用户名和密码,然后其他系统会代表用户进行身份验证
这种方式需要建立一个代理系统,这样可以保证用户信息的安全性
c.基于令牌的身份验证
用户在登录后,会获得一个令牌,这个令牌可以在多个系统上进行身份验证
这种方式需要建立一个令牌管理机制,这样可以保证用户信息的安全性
01.实战1
a.流程
1.用户输入用户名/密码登陆 ServiceA 系统
2.用户点击 ServiceA 系统中的某个按钮跳转到 ServiceB 系统,在跳转时需要带上 ServiceA 系统颁发的 ticket 票据
3.ServiceB 系统拿 ServiceA 系统的 ticket 去获取 ServiceA 系统的用户信息
4.ServiceA 系统会校验该 ticket 票据,然后将用户信息返回给 ServiceB 系统
5.ServiceB 系统根据用户信息生成 token 并附带重定向地址返回给 ServiceA 系统
6.ServiceA 系统就可以拿着获取的 token 去访问 ServiceB 系统的资源信息了
b.数据库
a.表结构
CREATE TABLE `sso_client_detail` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`platform_name` varchar(64) DEFAULT NULL COMMENT '应用名称',
`platform_id` varchar(64) NOT NULL COMMENT '应用标识',
`platform_secret` varchar(64) NOT NULL COMMENT '应用秘钥',
`encrypt_type` varchar(32) NOT NULL DEFAULT 'RSA' COMMENT '加密方式:AES或者RSA',
`public_key` varchar(1024) DEFAULT NULL COMMENT 'RSA加密的应用公钥',
`sso_url` varchar(128) DEFAULT NULL COMMENT '单点登录地址',
`remark` varchar(1024) DEFAULT NULL COMMENT '备注',
`create_date` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`update_date` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
`del_flag` bit(1) NOT NULL DEFAULT b'0' COMMENT '删除标志,0:正常;1:已删除',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='单点登陆信息表'
b.测试数据
INSERT INTO cheetah.sso_client_detail
(id, platform_name, platform_id, platform_secret, encrypt_type, public_key, sso_url, remark, create_date, create_by, update_date, update_by, del_flag)
VALUES(1, 'serviceA', 'A9mQUjun', 'Y6at4LexY5tguevJcKuaIioZ1vS3SDaULwOtXW63buBK4w2e1UEgrKmscjEq', 'RSA', NULL, 'http://127.0.0.1:8081/sso/url', NULL, '2023-05-23 16:55:26', 'system', '2023-05-30 13:16:16', NULL, 0);
c.说明
platform_id和platform_secret,阿Q是使用 apache 的 commons-lang3 包下的RandomStringUtils.randomAlphanumeric()方法生成的
sso_url就是上边提到的 ServiceB 系统的地址
encrypt_type、public_key在此方式中未使用,可以忽略
c.A跳转B
/**
* com.itaq.cheetah.serviceA.controller.PortalController#jump
* title:跳转 ServiceB
* <pre>
* 1. 前端点击Jump链接触发此接口调用
* 2. 此接口生成ticket并携带着请求 ServiceB
* 2.1 ServiceB拿着ticket请求我方服务获取用户信息
* 2.2 ServiceB获取到我方用户信息并进行数据同步
* 2.3 ServiceB返回一个链接,连接中带 token
* 3. 重定向到返回的链接实现登录
* </pre>
*
* @param req 单点跳转请求体
* @return ServiceB单点登录地址
*/
@PostMapping("/jumpB")
public WrapperResult<String> jump(@RequestBody @Validated SsoJumpReq req) {
log.debug("单点登录:{}", req.getPlatformName());
//1、判断该平台名称是否存在
SsoClientDetail one = iSsoClientDetailService.getOne(
new LambdaQueryWrapper<SsoClientDetail>().eq(SsoClientDetail::getPlatformName, req.getPlatformName())
);
if (Objects.isNull(one)) {
return WrapperResult.faild("不存在的app");
}
//2、校验本系统的 token,并从中获取用户信息
/*
* 示例
* Result<Token> result = authorizationApi.checkToken(req.getToken());
*/
//3、生成ticket,并将用户信息与其绑定存入redis
String ticket = UUID.randomUUID().toString().replaceAll("-", "");
UserInfo userInfo = new UserInfo();
userInfo.setId(1L);
userInfo.setUsername("阿Q");
redisTemplate.opsForValue().set(RedisConstants.TICKET_PREFIX + ticket, userInfo, 5, TimeUnit.MINUTES);
String ssoUrl = one.getSsoUrl();
Map<String, Object> data = new HashMap<>(1);
data.put("ticket", ticket);
//4、发送http请求,把ticket通过设置好的ssoUrl传给ServiceB
WrapperResult<SsoRespDto> ssoRespDto = HttpRequest
.get(ssoUrl)
.queryMap(data)
.connectTimeout(Duration.ofSeconds(120))
.readTimeout(Duration.ofSeconds(120))
.execute()
.asValue(new TypeReference<WrapperResult<SsoRespDto>>() {
});
log.info("请求ServiceB 结果:{}", JsonUtils.toPrettyString(ssoRespDto));
return WrapperResult.success(ssoRespDto.getData().getRedirectUrl());
}
d.B获取票据,并请求A获取用户信息
/**
* com.itaq.cheetah.serviceB.controller.SsoController#sso
* 获取票据,并请求ServiceA 获取用户信息
* @param ticket 票据
* @return 返回地址供sso跳转
* @throws JsonProcessingException 异常
*/
@GetMapping("/url")
public WrapperResult<SsoRespDto> sso(@RequestParam("ticket") String ticket) throws JsonProcessingException {
log.info("收到票据:{}", ticket);
//1.根据ticket换取ServiceA用户信息
Map<String, Object> param = new HashMap<>(1);
param.put("ticket", ticket);
String ssoUrl = "http://localhost:8081/getUser";
String s = HttpRequest
.get(ssoUrl)
.queryMap(param)
.connectTimeout(Duration.ofSeconds(120))
.readTimeout(Duration.ofSeconds(120))
.execute()
.asString();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
WrapperResult<SsoUserInfo> ssoUserInfoWrapperResult = objectMapper.readValue(s, new TypeReference<WrapperResult<SsoUserInfo>>() {
});
log.info("ticket登录结果:{}", new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(ssoUserInfoWrapperResult));
//2.获取到用户信息之后同步到本地数据库
log.info("获取用户信息同步数据库");
//3.生成token
log.info("生成token");
SsoRespDto respDto = new SsoRespDto();
//4、将ServiceA要跳转的地址返给ServiceA并携带 ServiceB 的token
respDto.setRedirectUrl("http://localhost:8082/index?token=123456");
WrapperResult<SsoRespDto> success = WrapperResult.success(respDto);
log.info(new ObjectMapper().writeValueAsString(success));
return success;
}
e.A提供的获取用户信息接口
/**
* com.itaq.cheetah.serviceA.controller.PortalController#loginByTicket
* 根据票据获取用户信息
* @param ticket 票据信息
* @return 用户信息
*/
@ApiOperation("根据ticket获取用户信息")
@GetMapping("/getUser")
public WrapperResult<SsoUserInfo> loginByTicket(@RequestParam("ticket") String ticket) {
log.info("收到票据:{}", ticket);
UserInfo userInfo = (UserInfo) redisTemplate.opsForValue().get(RedisConstants.TICKET_PREFIX + ticket);
if (Objects.isNull(userInfo)) {
return WrapperResult.faild("无法识别的票据信息");
}
//可能 userInfo 中只有少量的用户信息,此处省略了根据用户id查询用户和企业信息的过程,自行编写逻辑代码即可
SsoUserInfo ssoUserInfo = new SsoUserInfo();
BeanUtil.copyProperties(userInfo,ssoUserInfo);
return WrapperResult.success(ssoUserInfo);
}
02.实战2
a.流程
这次用ServiceB 系统单点登陆 ServiceA 方式:
1.用户输入用户名/密码登陆 ServiceB 系统
2.用户点击 ServiceB 系统中的某个按钮跳转到 ServiceA 系统,在跳转时需要带上 ServiceB 系统加密后的用户信息
3.ServiceA 系统拿到 ServiceB 系统加密后的用户信息后进行验签和解密操作
4.ServiceA 系统将用户信息保存到本地并生成 token 返回给 ServiceB 系统
5.ServiceB 系统拿到 ServiceA 系统返回的 token 就可以访问 ServiceA 系统的资源信息了
b.数据库
此种方式就用到了上边提到的数据库中的encrypt_type、public_key字段,其中 public_key 是 ServiceA 给 ServiceB 提供的
为了演示方便直接在application.yml中进行配置
c.B的配置
#本服务的appId和appSecret信息,该配置由serviceA提供
appId: A9mQUjun
appSecret: Y6at4LexY5tguevJcKuaIioZ1vS3SDaULwOtXW63buBK4w2e1UEgrKmscjEq
encrypt:
#加密方式 RSA | AES
type: RSA
#该配置是serviceA单点登陆serviceB用到的,此处是serviceB单点serviceA,所以用不到
#如果选择非对称加密,需要使用该配置;本服务的公私钥信息,该配置由serviceB自己生成,并将publicKey给serviceA
rsa:
publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0KLYE2Tv4qx/duxu8Qvq5ZN58yEjj/uwsxfs96pj+9iOOAUKLur8IIKjR/bi54GICUy0BHO6dzpWc0xqGK170F9NTv0bHe0qbh7jHgzq9MJrfcVD+XZAH17ho5tCGIo+z7CiC+rMWGTqmRopd/EQuzfx4Op4/85hoPlpKxdcxAfys0jpZ9tBMtROPsYKhCz01iDnHV2K95s4UwaQLbbx0VALVaXv1/4Yjw/PW4xK0syW/nqUtVqpfwPuX+fHf+bJ2s4kLnFBNwYAKFSU6znGmtJuq6aoxCunu2PbzI8xc7SYxHEfDqG8Zp29wtZcTJecWSDMBmywlaXjkXLzapvE7QIDAQAB
privateKey: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQotgTZO/irH927G7xC+rlk3nzISOP+7CzF+z3qmP72I44BQou6vwggqNH9uLngYgJTLQEc7p3OlZzTGoYrXvQX01O/Rsd7SpuHuMeDOr0wmt9xUP5dkAfXuGjm0IYij7PsKIL6sxYZOqZGil38RC7N/Hg6nj/zmGg+WkrF1zEB/KzSOln20Ey1E4+xgqELPTWIOcdXYr3mzhTBpAttvHRUAtVpe/X/hiPD89bjErSzJb+epS1Wql/A+5f58d/5snaziQucUE3BgAoVJTrOcaa0m6rpqjEK6e7Y9vMjzFztJjEcR8Oobxmnb3C1lxMl5xZIMwGbLCVpeORcvNqm8TtAgMBAAECggEBAKMhoQfRFYxMSkIHbluFcP5eyKylDbRoHOp726pvDUx/L/x3XFYBIHCfFOKRFSvk6SQ0WFFe176f27a9Wfu/sh7kVYNcflZw+YsvFXCKsy/70KZ/lr24izy8KHuPSyf6+E/WkW32Ah9fkNtzTFdfIzDv9m1hiIijq0x9l5C87KjNELnbvC0I6vwFOx0ak+JBbpaJ7IRjZxKZup7UIPvt9nbLzcbKelI83An2JUe8HNhrfWxH9UIyMOBoAY+bKCuAbUtHqSlImPiWyiCwE2/Fh7dmPSOAYYp9aZelnhd25jlR+eh4yaUoIID9ubmYVYbjcPW5SSNdfSZMfQ3oa79QeRUCgYEA6K4L+VLRiX8Dg7NCO1fM2+FTv2csTkPX6n7z/uu7kh0+wQDws+/C6Q906OtizvJBIJqFm2jPACNQCvnRixY1srgMJJlH/Rpeb4LtZGwdM1k0jAZIYQcBlGfaq3RaRI/+6+T0xdsh+7VF5A/smp/VXdK2xI3+JbLQ2wm9uN+3yZcCgYEA5Yvly7veDJYf2+8HIQkRhjWrWm1y5lCSe+HG+1ktfqnhN8YEOiPa71u0TXealL0T8EoKsqhWEjomxZ7n0jLigogz7OxxsGAE6HXAiKX0REINNYrq+1qNaqmkfLrhAJyg3JNgTSlb0xd56w7FSqOBttVL9INawGb1P98kYc5OzhsCgYBEfIY1urTGPcZxC2BhSzSXO7mEyv91ge6ZrQhwbj5lgYopEPfIXrgGFXCZ5j7NHu0ghZrx5WWYasxyjpmo0L65fgbE9wEDdLF7LRRmzJPDu2wGEwtW09MZNYBdmv++0ot8L4YEfr1/8xlBSZag5I7O8Oiu7gRyYDGtZy6are7QvQKBgQCaUZnUhOF7/rU+a4yUZf9VBeHD8k7LjaFdDWVzdvmB7P1PPJ185Lv8LN+jMORIWHD+GxjkEQ2ERXnpY7If+zuSW7Tk8/Reib7i9L7SXxc/iFRPCax9/NuTuKavgAdiHOp8P8v/M+3alS7OmuiCDDhZTT46DNDHBrCcFwzjgAo0vwKBgECBs6hEUVsYU74Uc64he8Zgkvj7wZ/yxnFlWmRRERprfBsuiY/y+DAf5ehezSRFpHXUrAkpeVXq2ydnr9BKTs6TV3AxlDMBNSndXsUYHENncR7tEHCSGRFTTu5jxdYA+k47R865Jh+2vQvPaPaXsEKSkDegvcFeUVR/yi5AsDub
#如果选择非对称加密,则需要使用该配置,该配置由serviceA提供
serviceA:
rsa:
publicKey: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2BF9EZCscKNXYADtulIDNHaMnoxV5Yu91jpv+LiWabW2EO51b8Sx8+Ei59EebM4r+SMal0k4L2Z+cNagQSP4Wvpss82/MkGO8bnAFSxS2SOKw+a+c2PxByWUxvHo4pbyYGFVWAGDXLiI+IqiO/fEFfpy6rYQzMLDnfgMFngdS4AZmRyTdMKbQs8mWqBE5nC0PoU39o/lFowfgelEjHE9vhjtTha67KhYY3n+ueuxsYdRQ40Mg7aQ0+Kt/qKoSn9yRWyx09DheFAkYl4ZCQfd0sMotLQ4BZtk0YWMNHOc1w+fL1bOumaj7AaJi6nM/VvwylLJyia2GjJIDrdTfHiOnwIDAQAB
d.A的配置
serviceA:
rsa:
privateKey: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDYEX0RkKxwo1dgAO26UgM0doyejFXli73WOm/4uJZptbYQ7nVvxLHz4SLn0R5sziv5IxqXSTgvZn5w1qBBI/ha+myzzb8yQY7xucAVLFLZI4rD5r5zY/EHJZTG8ejilvJgYVVYAYNcuIj4iqI798QV+nLqthDMwsOd+AwWeB1LgBmZHJN0wptCzyZaoETmcLQ+hTf2j+UWjB+B6USMcT2+GO1OFrrsqFhjef6567Gxh1FDjQyDtpDT4q3+oqhKf3JFbLHT0OF4UCRiXhkJB93Swyi0tDgFm2TRhYw0c5zXD58vVs66ZqPsBomLqcz9W/DKUsnKJrYaMkgOt1N8eI6fAgMBAAECggEAA5f23o3rcEwnLd+WFJ08lGjMWe63lwPF+oQqTJa1Wbi9+HYe2ecJlqbN79EYknKzZIdi79U17APmYnYPYEX64Xh8yljHr0xL1lVijneYQShILI3v6PdmkNndKZnoZ6xfB59WzgnoZ2hiTs/vdtPeHQd3VdQFX4J1wnDXsp/4zMKi1fDPt7rhqWrP5W6PXcoGGKIkN9zBlqrd1RBdnKXcwfFoHcFf2ikk6g3Kn50YMRe324eiHMm8z7W34Y3iSvZYHcKBMgsDklFerw1WOGHTN61oMr+8/NTtCsy1AnCH4PrwX/ryO17mh5xNzo/ZSZRRezR92/hmwUIuOO+3FWIE4QKBgQD05wYMVlGKn1fm+sn4hn+ErC6NifXj3MkNdjs8oSHzLrYr6ea6xIvbxesZvqzqz1Fh68bHjpJPOBKwgFnl7+dLXYLNmKjry1iK0o/MMZTtrGUwMEnWHRrpmxXH6B0cnBecZUReuJ9XfKZIfd9ksHHsUY7IGv1CHcblVP/IhrpnxwKBgQDh2/n0cAh1jygGevlXGK/rxuRSlbVgtxJWLAtY8Yolf2BklSiTwmqtp7nzNn8sxRvgfQCZaLqpjC/o/wtC3Ba5b4StJQejoXkCNhVmRdLbIQ2tUxwAElPjFhWf3C5/4B6uBeLyC9izp4wTSYbNbPKxcUGkkfpPbWdHsFZOG4gSaQKBgA/me/cLF6o3ZD6j478WBGt5vmAEKAnOSONt3LS4BXtDeiJpwkg4AJiZRgVa4uEv6qm/5B0KvacVDemVu8B5DfxPqvFsSvNcNXh16U4pnfC8c6loSTL0ms21+vkKsfEslT/bN1ArDnVgq28jdQCVkB/2v51wWycSxdoX5a+AR9P7AoGAMvTwZefI4M0VmLCyBKZ7OlS7Oq6wJ0vmhS6WuNB1/JPKaacFaqDYdKl82JSZCL7H1VQeiH4KbypDvOud3M3PCrNQWcga+x35MTiGh3aFZg8FCO/RR2rbJkbbRh/lFdC420ZUt4tYrt/ESK20DjDgaIxG5RxSPw1N2ey87A5mGtECgYEAlA12yuxBb6qmG3OUSlacSfcKnxZIC3L1IMqxlXL8eG3MB4dI6QYesc3odmaxmy9csgHs+pTyLfM3yB9Ocl572OW5WcEnod5o1EIup9hxB4IG/xSECYVFHlGKfIgbd/JhWtqloYZrwx+kVX/Iw02z18R32DRqBtK4MQ3klOYH86s=
e.B跳转A并加密用户信息
/**
* com.itaq.cheetah.serviceB.controller.ToServiceAController#redirectToServiceA
* 跳转 ServiceA 服务
*
* @return ServiceA返回的重定向链接
*/
@GetMapping
public WrapperResult<String> redirectToServiceA() {
//1、构建用户信息
SsoUserInfo data = buildSsoUserInfo();
Long timestamp = System.currentTimeMillis();
String flowId = UUID.randomUUID().toString();
String businessId = "sso";
String dataEncrypt;
String encryptType = configProperties.getEncryptType();
//2、根据配置选择哪种方式加密
switch (encryptType) {
case "AES":
AES aes = new AES(configProperties.getAppSecret().getBytes(StandardCharsets.UTF_8));
dataEncrypt = aes.encryptBase64(JsonUtils.toString(data), StandardCharsets.UTF_8);
break;
case "RSA":
RSA rsa = new RSA(AsymmetricAlgorithm.RSA_ECB_PKCS1.getValue(), null, configProperties.getServiceAPublicKey());
dataEncrypt = rsa.encryptBase64(JsonUtils.toString(data), StandardCharsets.UTF_8, KeyType.PublicKey);
break;
default:
return WrapperResult.faild("未配置加密方式");
}
//3、将以下信息进行签名
SsoSignSource build = SsoSignSource.builder()
.platformId(configProperties.getAppId())
.platformSecret(configProperties.getAppSecret())
.businessId(businessId)
.data(dataEncrypt)
.flowId(flowId)
.timestamp(timestamp)
.build();
String sign = build.sign();
log.info("sign source={}", JsonUtils.toPrettyString(build));
//4、构建请求体
ToServiceAReq req = ToServiceAReq.builder()
.platformId(configProperties.getAppId())
.businessId("sso")
.flowId(flowId)
.timestamp(timestamp)
.sign(sign)
.data(dataEncrypt)
.build();
//5、跳转A的操作
String s = HttpRequest.post("http://localhost:8081/serviceA")
.bodyString(JsonUtils.toString(req))
.execute()
.asString();
log.info("结果:{}", s);
return WrapperResult.success(s);
}
f.A获取用户信息后续操作
/**
* com.itaq.cheetah.serviceA.controller.ServiceAController#sso
*
* @return
*/
@PostMapping
public WrapperResult<SsoRespDto> sso(@VerifySign ToServiceAReq req) {
log.info("收到单点登录ServiceA的请求:{}", JsonUtils.toPrettyString(req));
//同步用户信息
//模拟登陆生成token
//返回拼接的url?token=xxx
//返回拼接的url?token=xxx
String url ="127.0.0.1:8081/index?token=xxx";
SsoRespDto ssoRespDto = new SsoRespDto();
ssoRespDto.setRedirectUrl(url);
return WrapperResult.success(ssoRespDto);
}
g.验签解密的逻辑去哪了?
通过注解的方式实现自动验签和解密的逻辑,至于具体的逻辑,大家可以参考项目源码自行解读!
https://gitee.com/zhangxiaoQ/cheetah-sso-doublec

2.5 [2]真实实战:2种
00.汇总
1.redis服务器:存放在服务器,如果是分布式环境,一般都会存储在 redis 中
2.jwt客户端:存储在客户端,服务器做验证,天然支持分布式
01.背景
a.一两个系统
在企业发展初期,使用的后台管理系统还比较少,一个或者两个
以电商系统为例,在起步阶段,可能只有一个商城下单系统和一个后端管理产品和库存的系统
b.多个系统
随着业务量越来越大,此时的业务系统会越来越复杂,项目会划分成多个组,每个组负责各自的领域,例如:A组负责商城系统的开发,B组负责支付系统的开发,C组负责库存系统的开发,D组负责物流跟踪系统的开发,E组负责每日业绩报表统计的开发...等等
规模变大的同时,人员也会逐渐的增多,以研发部来说,大致的人员就有这么几大类:研发人员、测试人员、运维人员、产品经理、技术支持等等
他们会频繁的登录各自的后端业务系统,然后进行办公
此时,我们可以设想一下,如果每个组都自己开发一套后端管理系统的登录,假如有10个这样的系统,同时一个新入职的同事需要每个系统都给他开放一个权限,那么我们可能需要给他开通10个账号
随着业务规模的扩大,大点的公司,可能高达一百多个业务系统,那岂不是要配置一百多个账号,让人去做这种操作,岂不伤天害理
c.单点登录
面对这种繁琐而且又无效的工作,IT大佬们想到一个办法,那就是开发一套登录系统
所有的业务系统都认可这套登录系统,那么就可以实现只需要登录一次,就可以访问其他相互信任的应用系统
这个登录系统,我们把它称为:单点登录系统
02.单体VS单点
a.【单体】后端系统登录
a.流程
在传统的单体后端系统中,简单点的操作,我们一般都会这么玩,用户使用账号、密码登录之后
服务器会给当前用户创建一个session会话,同时也会生成一个cookie,最后返回给前端
b.操作
当用户访问其他后端的服务时,我们只需要检查一下当前用户的session是否有效,如果无效,就再次跳转到登录页面;如果有效,就进入业务处理流程
但是,如果访问不同的域名系统时,这个cookie是无效的,因此不能跨系统访问,同时也不支持集群环境的共享
对于单点登录的场景,我们需要重新设计一套新的方案
b.【单点】登录系统登录
a.步骤1
当用户登录某应用系统时,应用系统会把将客户端传入的token,调用单点登录系统验证token合法性接口
如果不合法就会跳转到单点登录系统的登录页面;如果合法,就直接进入首页
进入登录页面之后,会让用户输入用户名、密码进行登录验证,如果验证成功之后,会返回一个有效的token
然后客户端会根据服务端返回的参数链接,跳转回之前要访问的应用系统
接着,应用系统会再次验证token的合法性,如果合法,就进入首页,流程结束
b.步骤2
引入单点登录系统后,接入的应用系统不需要关系用户登录这块,只需要对客户端的token做一下合法性鉴权操作就可以了
而单点登录系统,只需要做好用户的登录流程和鉴权并返回安全的token给客户端
有的项目,会将生成的token,存放在客户端的cookie中,这样做的目的,就是避免每次调用接口的时候都在url里面带上token
但是,浏览器只允许同域名下的cookies可以共享,对于不同的域名系统, cookie 是无法共享的
对于这种情况,我们可以先将 token 放入到url链接中,类似上面流程图中跳转思路
对于同一个应用系统,我们可以将token放入到 cookie 中,不同的应用系统,我们可以通过 url 链接进行传递,实现token的传输
03.token的存储方案
a.方案1:存放在服务器,如果是分布式环境,一般都会存储在 redis 中
a.介绍
存放在redis中,是一种比较常见的处理办法,最开始的时候也是这种处理办法
当用户登录成功之后,会将用户的信息作为value,用uuid作为key,存储到redis中,各个服务集群共享用户信息
b.流程
用户登录之后,将用户信息存在到redis,同时返回一个有效的token给客户端。
@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public TokenVO login(@RequestBody LoginDTO loginDTO){
//...参数合法性验证
//从数据库获取用户信息
User dbUser = userService.selectByUserNo(loginDTO.getUserNo);
//....用户、密码验证
//创建token
String token = UUID.randomUUID();
//将token和用户信息存储到redis,并设置有效期2个小时
redisUtil.save(token, dbUser, 2*60*60);
//定义返回结果
TokenVO result = new TokenVO();
//封装token
result.setToken(token);
//封装应用系统访问地址
result.setRedirectURL(loginDTO.getRedirectURL());
return result;
}
-------------------------------------------------------------------------------------------------
客户端收到登录成功之后,根据参数组合进行跳转到对应的应用系统
跳转示例如下:http://xxx.com/page.html?token=xxxxxx
各个应用系统,只需要编写一个过滤器TokenFilter对token参数进行验证拦截,即可实现对接,代码如下
-------------------------------------------------------------------------------------------------
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException, SecurityException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String requestUri = request.getRequestURI();
String contextPath = request.getContextPath();
String serviceName = request.getServerName();
//添加到白名单的URL放行
String[] excludeUrls = {
"(?:/images/|/css/|/js/|/template/|/static/|/web/|/constant/).+$",
"/user/login",
"/user/createImage"
};
for (String url : excludeUrls) {
if (requestUri.matches(contextPath + url) || (serviceName.matches(url))) {
filterChain.doFilter(request, response);
return;
}
}
//运行跨域探测
if(RequestMethod.OPTIONS.name().equals(request.getMethod())){
filterChain.doFilter(request, response);
return;
}
//检查token是否有效
final String token = request.getHeader("token");
if(StringUtils.isEmpty(token) || !redisUtil.exist(token)){
ResultMsg<Object> resultMsg = new ResultMsg<>(4000, "token已失效");
//封装跳转地址
resultMsg.setRedirectURL("http://sso.xxx.com?redirectURL=" + request.getRequestURL());
WebUtil.buildPrintWriter(response, resultMsg);
return;
}
//将用户信息,存入request中,方便后续获取
User user = redisUtil.get(token);
request.setAttribute("user", user);
filterChain.doFilter(request, response);
return;
}
-------------------------------------------------------------------------------------------------
上面返回的是json数据给前端,当然你还可以直接在服务器采用重定向进行跳转,具体根据自己的情况进行选择
由于每个应用系统都可能需要进行对接,因此我们可以将上面的方法封装成一个jar包,应用系统只需要依赖包即可完成对接
b.方案2:存储在客户端,服务器做验证,天然支持分布式
a.介绍
是将token存放客户端,这种方案就是服务端根据规则对数据进行加密生成一个签名串,这个签名串就是我们所说的token,最后返回给前端
因为加密的操作都是在服务端完成的,因此密钥的管理非常重要,不能泄露出去,不然很容易被黑客解密出来
b.最典型的应用就是JWT!
JWT 是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串
c.依赖
<!-- jwt支持 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
d.创建一个用户信息类,将会通过加密存放在token中
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UserToken implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private String userId;
/**
* 用户登录账户
*/
private String userNo;
/**
* 用户中文名
*/
private String userName;
}
e.创建一个JwtTokenUtil工具类,用于创建token、验证token
public class JwtTokenUtil {
//定义token返回头部
public static final String AUTH_HEADER_KEY = "Authorization";
//token前缀
public static final String TOKEN_PREFIX = "Bearer ";
//签名密钥
public static final String KEY = "q3t6w9z$C&F)J@NcQfTjWnZr4u7x";
//有效期默认为 2hour
public static final Long EXPIRATION_TIME = 1000L*60*60*2;
/**
* 创建TOKEN
* @param content
* @return
*/
public static String createToken(String content){
return TOKEN_PREFIX + JWT.create()
.withSubject(content)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.sign(Algorithm.HMAC512(KEY));
}
/**
* 验证token
* @param token
*/
public static String verifyToken(String token) throws Exception {
try {
return JWT.require(Algorithm.HMAC512(KEY))
.build()
.verify(token.replace(TOKEN_PREFIX, ""))
.getSubject();
} catch (TokenExpiredException e){
throw new Exception("token已失效,请重新登录",e);
} catch (JWTVerificationException e) {
throw new Exception("token验证失败!",e);
}
}
}
f.同时编写配置类,允许跨域,并且创建一个权限拦截器
@Slf4j
@Configuration
public class GlobalWebMvcConfig implements WebMvcConfigurer {
/**
* 重写父类提供的跨域请求处理的接口
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 添加映射路径
registry.addMapping("/**")
// 放行哪些原始域
.allowedOrigins("*")
// 是否发送Cookie信息
.allowCredentials(true)
// 放行哪些原始域(请求方式)
.allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
// 放行哪些原始域(头部信息)
.allowedHeaders("*")
// 暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)
.exposedHeaders("Server","Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin","Access-Control-Allow-Credentials");
}
/**
* 添加拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
//添加权限拦截器
registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
}
}
g.使用AuthenticationInterceptor拦截器对接口参数进行验证
@Slf4j
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从http请求头中取出token
final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);
//如果不是映射到方法,直接通过
if(!(handler instanceof HandlerMethod)){
return true;
}
//如果是方法探测,直接通过
if (HttpMethod.OPTIONS.equals(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return true;
}
//如果方法有JwtIgnore注解,直接通过
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method=handlerMethod.getMethod();
if (method.isAnnotationPresent(JwtIgnore.class)) {
JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class);
if(jwtIgnore.value()){
return true;
}
}
LocalAssert.isStringEmpty(token, "token为空,鉴权失败!");
//验证,并获取token内部信息
String userToken = JwtTokenUtil.verifyToken(token);
//将token放入本地缓存
WebContextUtil.setUserToken(userToken);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//方法结束后,移除缓存的token
WebContextUtil.removeUserToken();
}
}
h.在controller层用户登录之后,创建一个token,存放在头部即可
/**
* 登录
* @param userDto
* @return
*/
@JwtIgnore
@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public UserVo login(@RequestBody UserDto userDto, HttpServletResponse response){
//...参数合法性验证
//从数据库获取用户信息
User dbUser = userService.selectByUserNo(userDto.getUserNo);
//....用户、密码验证
//创建token,并将token放在响应头
UserToken userToken = new UserToken();
BeanUtils.copyProperties(dbUser,userToken);
String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));
response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token);
//定义返回结果
UserVo result = new UserVo();
BeanUtils.copyProperties(dbUser,result);
return result;
}

2.6 [3]无感刷新:4种
00.汇总
a.双Token机制
a.适用场景
通用场景
b.优点
安全性高:使用刷新令牌来获取新的访问令牌,减少令牌泄露风险
刷新频率低:访问令牌过期后才需要刷新,减少频繁请求
c.缺点
需要服务端支持双Token:需要服务端实现刷新令牌的逻辑
b.双Token+并发请求锁机制
a.适用场景
高并发场景
b.优点
解决并发刷新问题:通过锁机制确保刷新令牌请求的互斥性,避免重复刷新
c.缺点
实现较复杂:需要实现锁机制和队列管理,增加开发复杂度
c.前端定时刷新
a.适用场景
高活跃用户
b.优点
避免请求时突然过期:通过定时刷新令牌,确保令牌在使用时有效
c.缺点
需要定时器管理:需要在前端实现定时器逻辑,增加复杂性
d.服务端主动刷新
a.适用场景
服务端可控性强的场景
b.优点
客户端无需额外逻辑:服务器主动刷新令牌并通知客户端,简化客户端逻辑
c.缺点
服务端压力较大:服务器需要管理令牌刷新和通知逻辑,增加负担
01.双Token机制
a.定义
双Token机制使用两个不同的令牌:访问令牌(Access Token)和刷新令牌(Refresh Token)
访问令牌用于访问受保护资源,刷新令牌用于获取新的访问令牌
b.原理
访问令牌:短期有效,用于访问资源
刷新令牌:长期有效,用于获取新的访问令牌
当访问令牌过期时,使用刷新令牌获取新的访问令牌
c.常用API
POST /auth/token:用于获取访问令牌和刷新令牌
POST /auth/refresh:用于使用刷新令牌获取新的访问令牌
d.使用步骤
用户登录后,服务器返回访问令牌和刷新令牌
在访问令牌过期时,使用刷新令牌请求新的访问令牌
更新访问令牌并继续访问资源
e.代码示例
// 获取访问令牌和刷新令牌
async function login(username, password) {
const response = await fetch('/auth/token', {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
}
// 使用刷新令牌获取新的访问令牌
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('refreshToken');
const response = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
}
02.双Token+并发请求锁机制
a.定义
双Token+并发请求锁机制在使用刷新令牌时,确保不会有多个并发请求同时刷新令牌,避免重复刷新
b.原理
使用锁机制确保刷新令牌请求的互斥性
在刷新令牌时,阻止其他请求进行刷新操作
c.常用API
Promise:用于处理异步操作
Mutex:用于实现锁机制
d.使用步骤
在刷新令牌时,使用锁机制确保只有一个请求进行刷新操作
其他请求等待锁释放后再进行刷新
e.代码示例
let isRefreshing = false;
let refreshQueue = [];
function refreshAccessTokenWithLock() {
if (isRefreshing) {
return new Promise((resolve) => {
refreshQueue.push(resolve);
});
}
isRefreshing = true;
return refreshAccessToken().then(() => {
isRefreshing = false;
refreshQueue.forEach((resolve) => resolve());
refreshQueue = [];
});
}
// 使用刷新令牌获取新的访问令牌
async function refreshAccessToken() {
const refreshToken = localStorage.getItem('refreshToken');
const response = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken }),
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
}
03.前端定时刷新
a.定义
前端定时刷新机制通过定时器在访问令牌过期前主动刷新令牌,以确保会话保持活跃
b.原理
使用定时器在令牌过期前主动刷新访问令牌
定时器周期根据令牌的有效期设置
c.常用API
setInterval():用于设置定时器
d.使用步骤
用户登录后,设置定时器以定期刷新访问令牌
在定时器触发时,使用刷新令牌获取新的访问令牌
e.代码示例
// 设置定时器以定期刷新访问令牌
function startTokenRefreshInterval() {
const refreshInterval = 15 * 60 * 1000; // 每15分钟刷新一次
setInterval(refreshAccessToken, refreshInterval);
}
// 在用户登录后启动定时器
login('user', 'password').then(() => {
startTokenRefreshInterval();
});
04.服务端主动刷新
a.定义
服务端主动刷新机制由服务器在令牌过期前主动刷新令牌,并通知客户端更新令牌
b.原理
服务器在令牌过期前主动刷新令牌
服务器通过推送或轮询通知客户端更新令牌
c.常用API
WebSocket:用于服务器推送通知
Server-Sent Events (SSE):用于服务器推送通知
d.使用步骤
服务器在令牌过期前刷新令牌
服务器通过推送或轮询通知客户端更新令牌
e.代码示例
// 使用WebSocket接收服务器推送的令牌更新
const socket = new WebSocket('ws://example.com/token-refresh');
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.accessToken) {
localStorage.setItem('accessToken', data.accessToken);
}
};
// 服务器端逻辑(伪代码)
function refreshTokenForClient(clientId) {
const newAccessToken = generateNewAccessToken(clientId);
notifyClient(clientId, newAccessToken);
}
function notifyClient(clientId, accessToken) {
// 使用WebSocket或SSE推送令牌更新
}
2.7 [3]无感刷新:双token
01.2个Token
a.AccessToken(访问Token)
a.用途
用于访问受保护资源的凭证
b.有效期
通常较短,例如几分钟或几小时(在此例中为3小时)
c.颁发时机
用户进行身份验证并获得授权后,服务器颁发给客户端应用程序
d.使用场景
客户端使用AccessToken请求访问受保护资源,如API端点或特定功能
e.包含信息
通常包含用户ID、访问权限、过期时间等关键信息
f.安全性
由于有效期短,减少了被恶意使用的风险
b.RefreshToken(刷新Token)
a.用途
用于刷新AccessToken的凭证
b.有效期
通常较长,例如几天、几周甚至更长(在此例中为7天)
c.颁发时机
与AccessToken一起颁发给客户端应用程序
d.使用场景
当AccessToken过期时,客户端使用RefreshToken请求服务器刷新新的AccessToken
e.安全性
具有更高的安全性要求,传输过程中需要加密和保护
f.特点
不直接用于访问受保护资源
避免客户端在每次AccessToken过期时都需要用户重新进行身份验证
通过RefreshToken,客户端可以在AccessToken过期之前请求刷新新的AccessToken,以保持持续的访问能力
02.实现流程
a.登录阶段
用户登录后,系统生成两个Token并返回给前端
AccessToken:用于访问资源,设置有效期为3小时
RefreshToken:用于刷新AccessToken,设置有效期为7天
b.Token存储
前端将两个Token保存到localStorage(浏览器)或App的本地存储
c.资源访问
网关处校验AccessToken,若有效则放行;若无效则拒绝访问
d.Token续约
若AccessToken无效,前端携带RefreshToken请求续约接口。续约接口校验RefreshToken后,颁发新的AccessToken
03.常见问题
a.如何确保Token的安全性?
使用RSA非对称加密对令牌进行加密,确保令牌数据不被篡改。通过解密和签名校验,保证Token的完整性和安全性
b.为什么选择双Token方案?
双Token方案能够有效分离访问和刷新权限,增强系统安全性
AccessToken短期有效,减少被恶意使用的风险;RefreshToken长期有效,确保用户体验的连续性。
c.如何处理Token续约的请求?
续约接口不经过网关拦截,直接处理RefreshToken的校验和解密
若校验通过,生成新的AccessToken并返回给前端,确保用户能够继续访问资源
04.双Token机制
a.定义
双 Token 机制使用两个不同的令牌:访问令牌(Access Token)和刷新令牌(Refresh Token)
访问令牌用于访问受保护资源,刷新令牌用于获取新的访问令牌
b.获得双token
用户登录,服务端返回 Access Token 和 Refresh Token,访问接口时则携带 access_token 访问
Access Token 访问令牌:短期有效(如 15 分钟),用于业务请求
Refresh Token 刷新令牌:长期有效(如 7 天),用于刷新 Access Token
核心价值:通过分离 Token 的生命周期,减少 Access Token 的刷新频率,同时提高安全性
c.Access Token 过期
客户端检测到 Access Token 过期(如接口返回 401 错误)
使用 refresh_token 请求新的 Access Token,客户端更新本地存储
d.重新请求
使用新获取的access_token重新发起之前的请求
e.注意点
Access Token 存储在客户端(如 localStorage 或内存)
Refresh Token 存储在安全位置(如 HttpOnly Cookie),可有效防御 XSS攻击(跨站脚本攻击)
f.代码实现
import axios from 'axios'
const service = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000
})
// 请求拦截器:自动添加 Access Token
service.interceptors.request.use(config => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// 响应拦截器:处理 Token 过期
service.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 使用 Refresh Token 刷新 access Token
const accessToken = await refreshToken();
localStorage.setItem('access_token', accessToken);
// 更新请求头并重试原始请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return service(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录
router.push('/login')
}
}
return Promise.reject(error);
}
);
//刷新token函数
async function refreshToken() {
try {
const response = await service.get('/refresh', {
params: {
token: getRefreshTokenFromCookie(); // 从 HttpOnly Cookie 获取 Refresh Token
},
timeout: 30000, // 单独设置超时时间
});
return response.data.accessToken; // 返回新的 access_token
} catch (error) {
// 清除本地存储的 token
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
throw error; // 抛出错误
}
}
export default service;
2.8 [3]无感刷新:双Token+网关
01.令牌设计与生成
a.令牌定义
a.Access Token
有效期:30分钟(短效)
存储方式:客户端内存或非持久化存储(如JavaScript变量)
内容:用户ID、权限范围、设备指纹哈希、签发时间
格式:JWT(含exp声明)
b.Refresh Token
有效期:7天(长效)
存储方式:HttpOnly + Secure Cookie(防XSS)
内容:全局唯一标识符(UUID)、用户ID、设备指纹哈希
格式:不透明字符串(存储于Redis)
b.登录接口实现
@PostMapping("/login")
public R<LoginResult> login(@RequestBody LoginRequest request) {
// 1. 验证用户密码
LoginUser user = remoteUserService.authenticate(request);
// 2. 生成双Token
String accessToken = JwtUtils.generateAccessToken(user);
String refreshToken = UUID.randomUUID().toString();
// 3. 存储Refresh Token到Redis(绑定设备和用户)
String deviceFingerprint = buildDeviceFingerprint(request);
String redisKey = buildRefreshTokenKey(user.getUserId(), deviceFingerprint);
redisService.setEx(redisKey, refreshToken, 7, TimeUnit.DAYS);
// 4. 设置Refresh Token到Cookie
ResponseCookie cookie = ResponseCookie.from("refresh_token", refreshToken)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(7 * 24 * 3600)
.sameSite("Strict")
.build();
return R.ok(new LoginResult(accessToken))
.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
02.令牌刷新接口
a.刷新端点实现
@PostMapping("/auth/refresh")
public R<LoginResult> refreshToken(
@CookieValue(name = "refresh_token", required = false) String refreshToken,
HttpServletRequest request) {
// 1. 验证Refresh Token存在性
if (StringUtils.isEmpty(refreshToken)) {
return R.fail(HttpStatus.UNAUTHORIZED, "缺少刷新令牌");
}
// 2. 提取设备指纹
String deviceFingerprint = buildDeviceFingerprint(request);
// 3. 查询Redis验证有效性
String redisKey = buildRefreshTokenKeyFromRequest(request); // 根据请求生成Key
String storedToken = redisService.get(redisKey);
if (!refreshToken.equals(storedToken)) {
return R.fail(HttpStatus.UNAUTHORIZED, "刷新令牌无效");
}
// 4. 生成新Access Token
LoginUser user = getCurrentUser(); // 从上下文获取用户
String newAccessToken = JwtUtils.generateAccessToken(user);
// 5. 可选:刷新Refresh Token有效期(滑动过期)
redisService.expire(redisKey, 7, TimeUnit.DAYS);
return R.ok(new LoginResult(newAccessToken));
}
b.设备指纹生成逻辑
private String buildDeviceFingerprint(HttpServletRequest request) {
String ip = ServletUtils.getClientIP(request);
String userAgent = request.getHeader("User-Agent");
return Hashing.sha256().hashString(ip + userAgent, StandardCharsets.UTF_8).toString();
}
03.网关过滤器改造
a.验证流程调整
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 1. 白名单直接放行
if (isIgnorePath(request.getPath().toString())) {
return chain.filter(exchange);
}
// 2. 尝试获取Access Token
String accessToken = getAccessToken(request);
try {
// 3. 验证Access Token有效性
Claims claims = JwtUtils.parseToken(accessToken);
if (claims != null && isTokenValid(claims)) {
// 正常流程
return chain.filter(addHeaders(exchange, claims));
}
} catch (ExpiredJwtException ex) {
// 4. Access Token过期,尝试刷新
return handleTokenRefresh(exchange, chain, ex.getClaims());
}
// 5. 无有效令牌
return unauthorizedResponse(exchange, "请重新登录");
}
private Mono<Void> handleTokenRefresh(ServerWebExchange exchange,
GatewayFilterChain chain,
Claims expiredClaims) {
// 1. 获取Refresh Token
String refreshToken = getRefreshTokenFromCookie(exchange);
// 2. 调用刷新接口(内部转发)
return WebClient.create()
.post()
.uri("http://auth-service/auth/refresh")
.cookie("refresh_token", refreshToken)
.retrieve()
.bodyToMono(R.class)
.flatMap(result -> {
if (result.getCode() == HttpStatus.SUCCESS) {
// 3. 更新请求头中的Access Token
String newToken = result.getData().get("accessToken");
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header("Authorization", "Bearer " + newToken)
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
} else {
return unauthorizedResponse(exchange, "会话已过期");
}
});
}
04.安全增强措施
a.Token绑定设备
// JWT生成时加入设备指纹
public static String generateAccessToken(LoginUser user, HttpServletRequest request) {
String fingerprint = buildDeviceFingerprint(request);
return Jwts.builder()
.setSubject(user.getUsername())
.claim("user_id", user.getUserId())
.claim("fp", fingerprint)
.setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000))
.signWith(SECRET_KEY)
.compact();
}
// 网关验证时检查设备
private boolean validateDeviceFingerprint(Claims claims, HttpServletRequest request) {
String currentFp = buildDeviceFingerprint(request);
String tokenFp = claims.get("fp", String.class);
return currentFp.equals(tokenFp);
}
b.主动令牌撤销
// 注销接口
@PostMapping("/logout")
public R<Void> logout(HttpServletRequest request) {
// 1. 获取当前设备指纹
String fingerprint = buildDeviceFingerprint(request);
// 2. 删除Redis中的Refresh Token
String redisKey = buildRefreshTokenKey(getCurrentUserId(), fingerprint);
redisService.delete(redisKey);
// 3. 将Access Token加入黑名单(剩余有效期内拒绝)
String accessToken = getAccessToken(request);
redisService.setEx("token_blacklist:" + accessToken, "1",
JwtUtils.getRemainingTime(accessToken), TimeUnit.SECONDS);
// 4. 清除客户端Cookie
ResponseCookie cookie = ResponseCookie.from("refresh_token", "")
.maxAge(0)
.build();
return R.ok().addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
05.客户端实现示例
a.前端自动令牌管理
// axios拦截器
axios.interceptors.response.use(response => {
return response;
}, error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
// 调用刷新接口
return axios.post('/auth/refresh', {}, { withCredentials: true })
.then(res => {
const newToken = res.data.accessToken;
localStorage.setItem('access_token', newToken);
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return axios(originalRequest);
});
}
return Promise.reject(error);
});
b.静默刷新机制
// 定时检查Token有效期
setInterval(() => {
const token = localStorage.getItem('access_token');
if (token && isTokenExpiringSoon(token)) { // 剩余<5分钟
axios.post('/auth/refresh', {}, { withCredentials: true })
.then(res => {
localStorage.setItem('access_token', res.data.accessToken);
});
}
}, 300000); // 每5分钟检查
06.监控与运维
a.关键监控指标
指标名称监控方式报警阈值
刷新令牌失败率Prometheus计数器>5% (持续5分钟)
并发刷新冲突次数Redis分布式锁统计>10次/秒
黑名单令牌数量Redis键空间统计突增50%时告警
b.日志审计要点
# 成功刷新日志
[INFO] 用户[1001]通过设备[192.168.1.1|Chrome]刷新令牌,新有效期至2023-10-01 12:30
# 异常事件日志
[WARN] 检测到异常刷新请求,用户[1001]的设备[192.168.1.2|Firefox]与记录不匹配
07.部署与回滚
a.分阶段部署
a.Phase 1
先部署新的Auth Service(含双Token接口)
保持旧网关兼容两种令牌模式
b.Phase 2
部署新网关过滤器
前端逐步灰度发布新逻辑
c.Phase 3
完全禁用旧令牌模式
清理遗留的单一Token数据
b.回滚方案
a.紧急开关
@Value("${security.token.mode:SINGLE}")
private String tokenMode;
public Mono<Void> filter(...) {
if ("SINGLE".equals(tokenMode)) {
// 回退到旧逻辑
}
}
b.数据兼容
保持旧Token验证逻辑1周
双写Refresh Token到新旧Redis结构
2.9 [3]无感刷新:双Token+锁机制
01.流程
a.客户端
发送登录请求(账号密码/验证码等)
b.服务端
验证身份后生成 Access Token(短效) 和 Refresh Token(长效)
Access Token:返回给客户端存储(如 localStorage)
Refresh Token:通过 HttpOnly Cookie 返回(防 XSS)
02.请求拦截器:设置请求头token
a.说明
配置请求头添加token,判断请求url,设置不同token
b.代码
// 添加请求拦截器
service.interceptors.request.use(
config => {
if (config.url !== '/login') {
const accessToken = localStorage.getItem('access_token')
config.headers["Authorization"] = `Bearer ${accessToken}`
}
//判断是否是获取新token
if (config.url === '/refresh_token') {
const refreshToken = localStorage.getItem('refresh_token')
config.headers["Authorization"] = `Bearer ${refreshToken}`
}
return config
},
error => {
return Promise.reject(error)
}
)
03.响应拦截器:检测 Token 过期(401 错误)处理
a.说明
判断返回 的401,不是重新获取token的401
判断锁变量 isRefreshing:标记是否正在刷新 Token,则进行token刷新,处理高并发请求
请求队列 processQueue:存储等待刷新的请求
使用 refreshToken 请求新 accessToken
处理队列中的其他请求,以及重新发起失败的请求
释放锁
b.代码
// 添加响应拦截器
service.interceptors.response.use(
response => response, // 成功的响应直接返回
async error => {
const originalRequest = error.config;
//originalRequest._retry 是一个自定义属性,用于标记请求是否已经重试过。
//1、判断是不是token过期
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 显式标记请求为重试
//2、并且不是重新获取token的401,则进行token刷新
if (!isRefreshing) {
isRefreshing = true;
// 重新请求access_token
try {
const accessToken = await refreshToken()
// 更新localstorage中的access_token
localStorage.setItem('access_token', accessToken);
//配置请求头
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// 处理队列中的其他请求
processQueue(null, accessToken);
// 重新发起失败的请求
return service(originalRequest)
} catch (err) {
// 处理队列中的请求
processQueue(err, null);
console.log("刷新token失败,跳转登录界面", err)
// 重定向到登录页
router.push('/login')
} finally {
isRefreshing = false; //isRefreshing设置为false
}
}
else {
//如果正在刷新token,则将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
}
}
// 如果不是401错误,则直接抛出错误
return Promise.reject(error);
}
);
04.代码实现
import axios from 'axios'
const service = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000
})
//是否正在刷新token
let isRefreshing = false;
// 定义一个队列,用于存储失败的请求
let failedQueue = [];
// 处理队列中的请求
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error); // 拒绝请求
} else {
prom.resolve(token);// 使用新的 token 重新发起请求
}
});
if (!error) {
failedQueue = []; // 只有在成功刷新 token 时才清空队列
}
};
//刷新token函数
async function refreshToken() {
try {
const response = await service.get('/refresh', {
params: {
token: localStorage.getItem('refresh_token'),
},
timeout: 30000, // 单独设置超时时间
});
return response.data.accessToken; // 返回新的 access_token
} catch (error) {
// 清除本地存储的 token
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
throw error; // 抛出错误
}
}
// 添加请求拦截器
service.interceptors.request.use(
config => {
if (config.url !== '/login') {
const accessToken = localStorage.getItem('access_token')
config.headers["Authorization"] = `Bearer ${accessToken}`
}
//判断是否是获取新token
if (config.url === '/refresh_token') {
const refreshToken = localStorage.getItem('refresh_token')
config.headers["Authorization"] = `Bearer ${refreshToken}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 添加响应拦截器
service.interceptors.response.use(
response => response, // 成功的响应直接返回
async error => {
const originalRequest = error.config;
//originalRequest._retry 是一个自定义属性,用于标记请求是否已经重试过。
//1、判断是不是token过期
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 显式标记请求为重试
//2、并且不是重新获取token的401,则进行token刷新
if (!isRefreshing) {
isRefreshing = true;
// 重新请求access_token
try {
const accessToken = await refreshToken()
// 更新localstorage中的access_token
localStorage.setItem('access_token', accessToken);
//配置请求头
originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
// 处理队列中的其他请求
processQueue(null, accessToken);
// 重新发起失败的请求
return service(originalRequest)
} catch (err) {
// 处理队列中的请求
processQueue(err, null);
console.log("刷新token失败,跳转登录界面", err)
// 重定向到登录页
router.push('/login')
} finally {
isRefreshing = false; //isRefreshing设置为false
}
}
else {
//如果正在刷新token,则将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
}
}
// 如果不是401错误,则直接抛出错误
return Promise.reject(error);
}
);
export default service;
2.10 [3]无感刷新:前端定时刷新
01.实现原理
a.Token有效期
服务器为每个 Token 设置有效期,例如 30 分钟或 1 小时,在此期间内用户可访问受保护资源
b.定期检查
前端应用会定期检查 Token 有效性,通常通过轮询或心跳机制实现,以确保用户活动期间 Token 仍有效
c.刷新Token
当服务器指示 Token 失效时,前端应用立即请求认证服务器,使用 Refresh Token 获取新的访问 Token
后端通常返回长短不同过期时间的 Token,Refresh Token 存储在 LocalStorage 中,用于获取新的访问 Token
d.无缝切换
更新本地存储中的 Token 后,前端应用继续之前操作,使用户体验连贯无障碍
02.代码示例
a.在 Access Token 过期前主动刷新 Token,避免用户请求时突然过期
b.设置 Token 有效期
Access Token 有效期 15 分钟
在 Token 过期前 1 分钟主动刷新
c.定时刷新
客户端定时检查 Token 的剩余有效期
如果剩余时间小于阈值(如 1 分钟),则主动刷新 Token
d.代码实现
let refreshTimeout;
// 检查并刷新 Token 的函数
function checkAndRefreshToken() {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
const expiresIn = getTokenExpiresIn(accessToken); // 获取 Token 剩余时间
if (expiresIn < 60) { // 剩余时间小于 60 秒
refreshAccessToken().then(newAccessToken => {
localStorage.setItem('accessToken', newAccessToken);
// 重新设置定时器
refreshTimeout = setTimeout(checkAndRefreshToken, (getTokenExpiresIn(newAccessToken) - 60) * 1000);
});
} else {
// 设置定时器,在过期前 1 分钟刷新
refreshTimeout = setTimeout(checkAndRefreshToken, (expiresIn - 60) * 1000);
}
}
}
// 初始化时启动检查
checkAndRefreshToken();
03.代码示例
a.设置Token有效期
服务器为每个 token 分配了 30 分钟的有效期,确保在一定时间内用户的身份认证保持有效
b.心跳检测
前端应用通过优雅的心跳检测机制,每 5 分钟向服务器发送一个轻量级的请求
以检查当前 token 的有效性而不是频繁地发送请求
-----------------------------------------------------------------------------------------------------
// 使用 setInterval 定时发送心跳请求
setInterval(async () => {
try {
await checkTokenValidity();
} catch (error) {
// 处理错误,如重试逻辑或用户通知
console.error('Error checking token validity:', error);
}
}, 5 * 60 * 1000); // 每 5 分钟检查一次
c.Token有效性校验
前端应用向服务器的特定端点发送心跳请求,并附带当前 token 作为验证信息。服务器返回 token 的有效性状态
-----------------------------------------------------------------------------------------------------
async function checkTokenValidity() {
const token = localStorage.getItem('token');
if (!token) {
// 无 token,可能需用户重新登录
return;
}
try {
const response = await fetch('/api/heartbeat', {
headers: {
Authorization: `Bearer ${token}`
}
});
if (!response.ok) {
// Token 即将失效或已失效,进行刷新
await refreshToken();
}
} catch (error) {
// 处理网络错误或其他异常
console.error('Error checking token validity:', error);
}
}
d.Token刷新机制
当 token 失效或即将失效时,前端应用使用 refresh token 向认证服务器发送请求,获取新的访问 token
-----------------------------------------------------------------------------------------------------
async function refreshToken() {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// 无 refresh token,可能需用户重新登录
return;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
// 更新本地存储的 token
localStorage.setItem('token', data.accessToken);
// 可选择性地更新有效期计时器
} else {
// 刷新 token 失败,可能需要用户重新登录或执行其他补救措施
console.error('Failed to refresh token:', response.status);
}
} catch (error) {
// 处理网络错误或其他异常
console.error('Error refreshing token:', error);
}
}
e.安全性考虑
刷新 token 也应设定有效期,并在过期后要求用户重新登录,以增强安全性
在实际应用中,还应考虑使用 HTTPS 来保护传输过程中的 token,防止中间人攻击
对于敏感操作,如更改密码或进行高价值交易,可能还需要额外的验证步骤,如短信验证或生物识别
2.11 [3]无感刷新:服务端主动刷新
01.服务端主动刷新
a.服务端可以在每次请求时检查 Token 的剩余有效期,并在接近过期时返回新的 Token
b.服务端检查
每次请求时,服务端轮询Token的剩余有效期
判断,如果剩余时间小于阈值(如 1 分钟),返回新的 Access Token
c.客户端更新
客户端检测到响应中包含新的 Token,更新本地存储
d.代码实现
// 服务端逻辑
app.use((req, res, next) => {
const accessToken = req.headers.authorization?.split(' ')[1];
if (accessToken) {
const expiresIn = getTokenExpiresIn(accessToken);
if (expiresIn < 60) { // 剩余时间小于 60 秒
const newAccessToken = generateAccessToken(req.user);
res.set('New-Access-Token', newAccessToken);
}
}
next();
});
// 客户端逻辑
service.interceptors.response.use(response => {
const newAccessToken = response.headers['new-access-token'];
if (newAccessToken) {
localStorage.setItem('accessToken', newAccessToken);
}
return response;
});
2.12 [3]无感刷新:未完成,发送其他请求
01.token还没有刷新完成时,发送了其他请求需要如何处理?
a.请求队列化
当开始刷新token时,将其他待发送的请求暂时放入队列中等待。一旦token刷新成功
再依次从队列中取出请求并使用新的token发送。这样可以确保在token有效之后再进行请求,避免请求失败
b.使用Promise或async/await处理异步
当发送请求时,可以返回一个Promise对象,并在token刷新完成后resolve这个Promise
这样,其他请求可以await这个Promise,直到token刷新完成后再发送
c.设置请求重试机制
如果请求因为token失效而失败,可以设置请求重试机制。在请求失败后,检查是否是token失效导致的
如果是,则等待token刷新完成后再次尝试发送请求
d.缓存机制
对于某些可以缓存的数据,如果请求失败是因为token失效,可以先从缓存中获取数据,然后再异步刷新token和更新缓存
e.降级处理
如果token刷新失败或者耗时过长,可以考虑降级处理,比如使用匿名访问或者提示用户登录
f.前端状态管理
使用前端状态管理工具(如Redux, Vuex等)来管理token的刷新状态
当开始刷新token时,将状态设置为“正在刷新”,其他请求在发送前检查这个状态
如果正在刷新则等待;当token刷新成功后,更新状态并触发重新发送队列中的请求
g.服务端处理
除了前端处理,服务端也可以在接收到token失效的请求时,返回特定的状态码(如401 Unauthorized)
并在响应头中提供刷新token的提示或URL。前端接收到这个状态码后,可以触发token的刷新流程
02.伪代码示例
// 假设有一个请求队列
let requestQueue = [];
// 用于处理token刷新的函数
async function refreshTokenAndSendRequest(request) {
try {
// 如果队列中已经有正在进行的token刷新,则等待它完成
if (isTokenRefreshInProgress) {
return new Promise((resolve, reject) => {
requestQueue.push({ request, resolve, reject });
});
}
// 标记token刷新正在进行中
isTokenRefreshInProgress = true;
// 刷新token
const newToken = await refreshToken();
// 使用新token发送请求
const response = await request(newToken);
// 标记token刷新完成
isTokenRefreshInProgress = false;
// 处理队列中的其他请求
processQueuedRequests();
return response;
} catch (error) {
// 处理错误,如重试逻辑或用户通知
console.error('Error refreshing token and sending request:', error);
// 标记token刷新完成
isTokenRefreshInProgress = false;
// 处理队列中的其他请求(可能需要特殊处理错误情况)
processQueuedRequests();
throw error;
}
}
// 处理队列中的请求
function processQueuedRequests() {
while (requestQueue.length > 0) {
const { request, resolve, reject } = requestQueue.shift();
refreshTokenAndSendRequest(request)
.then(resolve)
.catch(reject);
}
}
// 发送请求时调用此函数
async function sendRequestWithTokenRefresh(request) {
return refreshTokenAndSendRequest(request);
}
// 示例使用
sendRequestWithTokenRefresh(someApiRequest)
.then(response => {
// 处理响应
})
.catch(error => {
// 处理错误
});
2.13 [4]单设备登录
00.汇总
a.回答
基于Token版本控制(推荐)
基于WebSocket的实时强制下线
b.对比
a.基于 Token 版本控制
优点:逻辑简单,后端维护 Token 版本即可,兼容性好
缺点:需要前端主动处理 401,可能有短暂的延迟
b.基于 WebSocket 实时强制下线
优点:即时性强,用户体验更好
缺点:需要 WebSocket 连接,维护连接状态,断连时需要额外处理
c.方案结合使用
可以以 WebSocket 优先,当 WebSocket 连接异常时,后端回退到 Token 版本校验
这样既能保证即时强制下线,也能兼容不支持 WebSocket 的场景
在实际应用中,根据用户设备和网络环境的不同,灵活切换验证方式,确保系统的稳定性和用户体验
例如,在网络环境较好、设备支持 WebSocket 的情况下,优先使用 WebSocket 实现即时下线
而在网络不稳定或设备不支持 WebSocket 时,自动切换到 Token 版本校验,保障系统的正常运行
同时,还可以在系统中加入日志记录功能,对每次登录和下线操作进行详细记录,以便后续的问题排查和数据分析
01.基于 Token 版本控制(推荐)
a.核心思想
Token 版本号(token_version)充当唯一凭证,每次新设备登录时,版本号 +1,旧 Token 失效
此机制通过对 Token 版本的精确管理,使得服务器能够准确判断当前请求的合法性
有效保障单设备登录的安全性和稳定性
b.流程拆解
a.用户首次登录
用户登录后,数据库中的 token_version 设为 1。这一步骤为后续的版本控制奠定基础,明确了初始状态
后端生成 JWT Token,并在负载(payload)中加入 {userId: 1, tokenVersion: 1}。JWT Token 作为用户身份验证的关键凭证,包含了用户 ID 和当前的 Token 版本信息,确保身份验证的准确性和完整性
返回 Token 给前端,前端存储在 localStorage 或请求头字段(Authorization)中。前端妥善存储 Token,以便后续请求中携带,实现用户身份的验证和识别
b.用户在新设备登录
新设备登录后,后端查询该用户的 token_version,然后 +1,更新到数据库(如 token_version = 2)。后端通过查询和更新操作,确保数据库中记录的 Token 版本始终是最新的,为后续的验证提供准确依据
生成新的 Token {userId: 1, tokenVersion: 2},返回给新设备。新的 Token 携带了更新后的版本信息,保证新设备登录的合法性和有效性
c.旧设备携带 Token 访问接口
旧设备的 Token 仍然是 {userId: 1, tokenVersion: 1},但数据库中 token_version = 2。由于版本不一致,旧设备的 Token 已无法通过验证
服务器验证 Token,发现 tokenVersion !== 数据库的 token_version,返回 401,拒绝访问。服务器通过严格的验证机制,确保只有合法的 Token 才能访问接口,保障系统的安全性
d.旧设备强制下线
旧设备前端收到 401 响应后:
清除本地 Token,确保本地不再保存已失效的凭证
跳转到登录页,引导用户重新进行登录操作
提示 "账号已在其他设备登录,请重新登录",向用户清晰解释下线原因,提升用户体验
c.方案核心
该方案核心是维护了 token_version 的版本值,是实现单设备登录最简单的方案
通过简洁的版本控制逻辑,有效实现了单设备登录的功能,降低了开发和维护的难度
02.基于 WebSocket 的实时强制下线
a.核心思想
每次用户在新设备登录时,服务器通过 WebSocket 通知旧设备下线,旧设备收到消息后清除 Token 并强制登出
此方案利用 WebSocket 的实时双向通讯特性,实现了对旧设备的即时下线控制,极大提升了用户体验和系统安全性
b.流程拆解
a.用户首次登录
用户登录后,服务器建立 WebSocket 连接,并在 Redis 或者数据库中存储对应状态
如:{userId: 1, socketId: "xm123"} 以记录该设备的 WebSocket 连接 ID
服务器通过建立连接和存储状态,为后续的实时通讯和设备管理提供了基础
b.用户在新设备登录
新设备登录后,服务器检测到该用户已有活跃连接。服务器通过实时监测,能够及时发现同一用户在不同设备上的登录行为
给旧设备发送 WebSocket 消息:"你的账号已在其他设备登录,请重新登录"。通过发送明确的通知消息,告知旧设备用户账号的异常登录情况
删除旧设备的 WebSocket 连接记录,确保系统中只保留当前活跃设备的连接信息
新设备建立 WebSocket 连接,存储 {userId: 1, socketId: "xm456"}。新设备成功建立连接并存储相关信息,保证后续通讯的顺畅
c.旧设备收到 WebSocket 消息
旧设备监听到 "被踢下线" 消息:
清除本地 Token,确保本地不再保存已失效的凭证
自动跳转到登录页,引导用户重新进行登录操作
提示 "账号已在其他设备登录",向用户清晰解释下线原因,提升用户体验
c.方案核心
与基于 Token 版本控制相比,此方案核心是利用了 WebSocket 双向通讯的能力来实现强制下线的功能
好处是可以做到即时响应,麻烦的地方是需要重新对接 ws 协议,增加了开发和维护的复杂性
2.14 [4]SSO、OAuth2
00.汇总
SSO:简化用户在多个应用系统中的登录流程
OAuth2:保护用户的敏感信息,并允许第三方应用代表用户访问特定资源
SSO和OAuth2都是用于管理用户身份验证和授权的协议
01.目标
SSO:简化用户在多个应用系统中的登录流程,让用户只需要登录一次就可以访问所有授权的应用系统,提高用户体验和效率
OAuth2:允许第三方应用代表用户获得访问特定资源的权限,同时保护用户的敏感信息(如密码)不被泄露
02.应用场景
SSO:用于大型企业内部或相关联的系统之间,用户只需要在一个地方(如企业门户)进行登录,就可以访问多个内部系统
OAuth2:应用于第三方应用需要访问用户存储在服务提供商(如 Google、Facebook)中的资源时,用户授权第三方应用访问其资源,而无需将用户名和密码直接提供给第三方应用
03.实现方式
SSO:通常依赖于一个集中的认证中心(Authentication Server),用户在这个中心进行登录,并获得一个全局会话或令牌(Token),然后在访问其他应用系统时,这个令牌会被用来验证用户的身份和权限
OAuth2:涉及四个角色:资源所有者(Resource Owner)、授权服务器(Authorization Server)、客户端(Client)和资源服务器(Resource Server)。用户(资源所有者)授权客户端访问其资源,授权服务器颁发访问令牌给客户端,客户端使用这个令牌访问资源服务器上的资源
2.15 [4]token天然解决CSRF问题
01.Token是怎么解决CSRF的?
Cookie在每次请求发送的时候都会携带导致被攻击了
而Token不会每次发请求都要携带,所以天然的杜绝了CSRF请求
02.CSRF
CSRF(Cross-site request forgery)也被称为跨站请求伪造
一般来说,攻击者通过伪造用户的浏览器的请求,向访问一个用户自己曾经认证访问过的网站发送消息
使目标网站接收并误以为是用户的真实操作而去执行命令。常用于盗取账号、转账、发送虚假消息等
攻击者利用网站对请求的验证漏洞而实现这样的攻击行为,网站能够确认请求来源于用户的浏览器
却不能验证请求是否源于用户的真实意愿下的操作行为
那么问题来了,攻击者是怎么伪造用户的浏览器的请求?Cookie&Session的认证模式
2.16 [4]token鉴权,session防止token滥用
00.汇总
a.回答
session使用来保存token的,但是传token到前端大部分网站都用cookie
b.说明
a.Session保存Token
在某些实现中,Session可以用于保存Token,特别是在需要在服务器端管理Token的情况下
然而,Session的主要作用是管理用户的会话状态,而不是专门用于保存Token
b.Token传输到前端
Token可以通过多种方式传输到前端,使用Cookie是常见的做法,因为它可以简化Token的管理和传输
01.JWT的作用
a.用户认证
JWT 包含了用户的身份信息和权限信息,客户端每次请求时将 JWT 发送给服务器,服务器通过验证 JWT 来确认用户身份
b.无状态性
JWT 不需要在服务器端存储用户会话信息,因此服务器可以是无状态的,便于扩展和负载均衡
02.Session的作用
a.附加的安全层
即使 JWT 是无状态的,但在某些应用场景中,仅依赖 JWT 可能存在一些安全问题
例如 Token 的泄露或滥用。Session 可以作为一个额外的安全层,确保 Token 即使有效
也必须在服务器的 Session 管理器中存在对应的会话
b.管理 Token 的生命周期
通过 Session,可以更方便地管理 Token 的生命周期,例如强制用户重新登录、手动注销 Token 等操作
c.控制“记住我”功能
如果用户选择了“记住我”选项,Session 可以记录这个状态,并在 JWT 过期后
通过 Session 来决定是否允许继续使用旧的 Token
03.为什么需要创建 Session
a.防止 Token 滥用
通过在服务器端验证 Session,可以确保即使 Token 有效
也必须是经过服务器端认证的,从而防止 Token 被恶意使用
b.支持用户主动注销
当用户选择注销时,可以直接删除服务器端的 Session 记录
确保 Token 即使没有过期,也无法再被使用
c.提供更精细的控制
通过 Session,可以实现更精细的权限控制和用户状态管理,例如强制下线、会话过期时间控制等
d.状态追踪
在某些场景下,追踪用户状态是必要的,例如监控用户的活跃度、登录历史等,这些信息可以通过 Session 进行管理
04.结合JWT和Session的优势
a.无状态认证
JWT 可以实现无状态认证,便于系统的水平扩展和负载均衡
b.状态管理和安全性
Session 可以提供额外的状态管理和安全性,确保 Token 的使用更加安全可靠
05.代码示例
a.用户登录时创建 JWT 和 Session
public LoginResponse login(String username, String password) throws AuthException {
// 验证用户名和密码
User user = userService.authenticate(username, password);
if (user == null) {
throw new AuthException("Invalid username or password");
}
// 生成 JWT Token
String token = createJwt(user.getId(), user.getRoles());
// 创建会话
sessionManagerApi.createSession(token, user);
// 返回 Token
return new LoginResponse(token);
}
public void createSession(String token, User user) {
LoginUser loginUser = new LoginUser();
loginUser.setToken(token);
loginUser.setUserId(user.getId());
loginUser.setRoles(user.getRoles());
sessionManagerApi.saveSession(token, loginUser);
}
b.请求验证时验证 JWT 的有效性,然后检查 Session 中是否存在对应的会话
@Override
public DefaultJwtPayload validateToken(String token) throws AuthException {
try {
// 1. 先校验 jwt token 本身是否有问题
JwtContext.me().validateTokenWithException(token);
// 2. 获取 jwt 的 payload
DefaultJwtPayload defaultPayload = JwtContext.me().getDefaultPayload(token);
// 3. 如果是 7 天免登陆,则不校验 session 过期
if (defaultPayload.getRememberMe()) {
return defaultPayload;
}
// 4. 判断 session 里是否有这个 token
LoginUser session = sessionManagerApi.getSession(token);
if (session == null) {
throw new AuthException(AUTH_EXPIRED_ERROR);
}
return defaultPayload;
} catch (JwtException jwtException) {
if (JwtExceptionEnum.JWT_EXPIRED_ERROR.getErrorCode().equals(jwtException.getErrorCode())) {
throw new AuthException(AUTH_EXPIRED_ERROR);
} else {
throw new AuthException(TOKEN_PARSE_ERROR);
}
} catch (io.jsonwebtoken.JwtException jwtSelfException) {
throw new AuthException(TOKEN_PARSE_ERROR);
}
}
3 SpringSecurity
3.1 [1]图示
01.流程说明
a.客户端发起一个请求,进入Security过滤器链
略
b.当到LogoutFilter的时候判断是否是登出路径
如果是,登出路径则到logoutHandler,如果登出成功则到logoutSuccessHandler登出成功处理,如果登出失败则由ExceptionTranslationFilter
如果不是,登出路径则直接进入下一个过滤器
c.当到UsernamePasswordAuthenticationFilter的时候判断是否为登录路径
如果是,则进入该过滤器进行登录操作,如果登录失败则到 AuthenticationFailureHandler 登录失败处理器处理,如果登录成功则到 AuthenticationSuccessHandler 登录成功处理器处理
如果不是登录请求则不进入该过滤器
d.当到FilterSecurityInterceptor的时候会拿到uri
根据 uri 去找对应的鉴权管理器,鉴权管理器做鉴权工作
鉴权成功则到 Controller 层否则到 AccessDeniedHandler 鉴权失败处理器处理

3.2 [1]概念:4个
01.定义
认证(Authentication):认证是验证用户身份的过程,通常通过用户名和密码进行。它确保用户是他们声称的身份
授权(Authorization):授权是在用户通过认证后,确定他们是否有权访问特定资源或执行特定操作的过程
用户主体(Principal):用户主体是一个表示已认证用户的对象,通常包含用户名和角色信息
角色(Role):角色定义了用户的权限级别,通常用于访问控制,以决定用户可以执行哪些操作
访问控制(Access Control):访问控制是定义谁可以访问应用程序的哪些资源和操作的机制
02.原理
认证原理:通过验证用户提供的凭证(如用户名和密码)来确认用户身份
授权原理:基于用户的角色和权限来决定用户可以访问哪些资源和执行哪些操作
用户主体原理:用户主体是认证过程的结果,包含用户的身份信息
角色原理:角色是权限的集合,用户通过角色获得相应的权限
访问控制原理:通过定义规则和策略来管理用户对资源的访问
03.常用API
认证API:如OAuth、OpenID Connect、JWT等
授权API:如RBAC(基于角色的访问控制)、ABAC(基于属性的访问控制)
用户主体API:通常在认证框架中提供,如Spring Security中的UserDetails
角色API:角色管理通常在权限管理系统中实现
访问控制API:如ACL(访问控制列表)、IAM(身份和访问管理)
04.使用步骤
a.认证步骤
用户输入凭证(用户名和密码)
系统验证凭证的有效性
如果有效,创建用户主体
b.授权步骤
根据用户主体获取用户角色
检查用户角色是否有访问请求资源的权限
c.用户主体步骤
在认证成功后,创建用户主体对象
用户主体包含用户的身份信息和角色
d.角色步骤
定义角色及其权限
将用户分配到相应的角色
e.访问控制步骤
定义访问控制策略
根据策略检查用户对资源的访问权限
05.每个场景对应的代码示例
a.认证示例(使用Spring Security)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("{noop}password").roles("USER");
}
}
b.授权示例(使用Spring Security)
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated();
}
}
c.用户主体示例
public class CustomUserDetails implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
// getters and setters
}
d.角色示例
public enum Role {
USER, ADMIN;
}
e.访问控制示例
public class AccessControlService {
public boolean hasAccess(User user, Resource resource) {
// Check user's roles and resource permissions
return user.getRoles().contains(resource.getRequiredRole());
}
}
3.3 [1]原理:7步
01.核心原理:拦截器
Spring Security会在Web应用程序的过滤器链中添加一组自定义的过滤器,这些过滤器可以实现身份验证和授权功能
当用户请求资源时,Spring Security会拦截请求,并使用配置的身份验证机制来验证用户身份
如果身份验证成功,Spring Security会授权用户访问所请求的资源
02.工作原理:7步
1.用户请求Web应用程序的受保护资源
2.Spring Security拦截请求,并尝试获取用户的身份验证信息
3.如果用户没有经过身份验证,Spring Security将向用户显示一个登录页面,并要求用户提供有效的凭据(用户名和密码)
4.一旦用户提供了有效的凭据,Spring Security将验证这些凭据,并创建一个已认证的安全上下文(SecurityContext)对象
5.安全上下文对象包含已认证的用户信息,包括用户名、角色和授权信息
6.在接下来的请求中,Spring Security将使用已经认证的安全上下文对象来判断用户是否有权访问受保护的资源
7.如果用户有权访问资源,Spring Security将允许用户访问资源,否则将返回一个错误信息
3.4 [1]组件:8个
01.定义
a.SecurityContextHolder
用于存储与当前线程关联的安全上下文,通常包含认证信息
b.Authentication
表示用户的认证信息,包括凭证和权限
c.UserDetails
表示用户的详细信息,如用户名、密码和权限
d.UserDetailsService
用于加载用户详细信息的接口,通常从数据库或其他存储中获取用户信息
e.AuthenticationManager
负责处理认证请求,验证用户凭证
f.AccessDecisionManager
负责授权决策,决定用户是否有权限访问某个资源
g.FilterChainProxy
负责处理HTTP请求的过滤器链,应用安全过滤器
h.SecurityFilterChain
由一系列安全过滤器组成的链,用于处理请求的安全性
02.原理
a.SecurityContextHolder原理
通过线程本地存储安全上下文,使得应用程序可以在任何地方访问当前用户的认证信息。
b.Authentication原理
封装用户的认证信息,提供用户身份和权限的访问接口。
c.UserDetails原理
提供用户的详细信息,支持认证和授权过程。
d.UserDetailsService原理
通过实现该接口,应用程序可以自定义用户信息的加载逻辑。
e.AuthenticationManager原理
负责验证用户凭证,通常通过配置认证提供者来实现。
f.AccessDecisionManager原理
根据用户的权限和请求的资源,做出授权决策。
g.FilterChainProxy原理
通过过滤器链处理HTTP请求,确保请求经过必要的安全检查。
h.SecurityFilterChain原理
定义一系列过滤器,按顺序处理请求,确保安全性。
03.常用API
a.SecurityContextHolder API
SecurityContextHolder.getContext()
b.Authentication API
Authentication.getAuthorities()
c.UserDetails API
UserDetails.getUsername()
d.UserDetailsService API
UserDetailsService.loadUserByUsername(String username)
e.AuthenticationManager API
AuthenticationManager.authenticate(Authentication authentication)
f.AccessDecisionManager API
AccessDecisionManager.decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
g.FilterChainProxy API
FilterChainProxy.doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
h.SecurityFilterChain API
SecurityFilterChain.matches(HttpServletRequest request)
04.使用步骤
a.SecurityContextHolder使用步骤
在认证成功后,将认证信息存储到SecurityContextHolder
在应用程序中通过SecurityContextHolder访问当前用户信息
b.Authentication使用步骤
创建Authentication对象,包含用户凭证和权限
在认证过程中使用AuthenticationManager验证Authentication对象
c.UserDetails使用步骤
实现UserDetails接口,提供用户详细信息
在认证过程中使用UserDetailsService加载用户信息
d.UserDetailsService使用步骤
实现UserDetailsService接口,定义loadUserByUsername方法
在认证过程中调用loadUserByUsername加载用户信息
e.AuthenticationManager使用步骤
配置AuthenticationManager,定义认证提供者
使用authenticate方法验证用户凭证
f.AccessDecisionManager使用步骤
配置AccessDecisionManager,定义授权策略
在授权过程中调用decide方法做出授权决策
g.FilterChainProxy使用步骤
配置FilterChainProxy,定义过滤器链
在请求处理过程中应用过滤器链
h.SecurityFilterChain使用步骤
定义SecurityFilterChain,配置过滤器
在请求处理过程中匹配请求并应用过滤器
05.每个场景对应的代码示例
a.SecurityContextHolder示例
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
b.Authentication示例
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken("user", "password");
Authentication authResult = authenticationManager.authenticate(authRequest);
c.UserDetails示例
public class CustomUserDetails implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
// Implement UserDetails methods
}
d.UserDetailsService示例
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// Load user from database
return new CustomUserDetails(username, "password", authorities);
}
}
e.AuthenticationManager示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService);
}
}
f.AccessDecisionManager示例
public class CustomAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
// Implement decision logic
}
}
g.FilterChainProxy示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public FilterChainProxy filterChainProxy() {
return new FilterChainProxy(securityFilterChain());
}
}
h.SecurityFilterChain示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public SecurityFilterChain securityFilterChain() {
return new DefaultSecurityFilterChain(new AntPathRequestMatcher("/**"), new MySecurityFilter());
}
}
3.5 [1]微服务:不合适
01.SpringSecurity在单体架构下,做权限是没有问题,但是在微服务模式架构下,肯定是不合适的
微服务架构下做权限,使用【jwt+gateway+shiro】,或者saToken
在微服务架构下,直接使用 Spring Security 可能会遇到分布式系统的复杂性、认证和授权的集中管理、跨服务的安全上下文传递等问题
需要结合 API 网关、OAuth2、JWT、Spring Cloud Security 等工具和组件,来实现一个健壮的分布式安全解决方案
02.主要原因
a.分布式系统的复杂性
a.单体架构
所有的功能和模块都在一个应用中,Spring Security 可以直接管理所有的安全配置和状态。
b.微服务架构
系统被拆分成多个独立的服务,每个服务可能有不同的安全需求和配置。
Spring Security 在这种情况下需要在每个服务中单独配置和管理,增加了复杂性和维护成本。
b.认证和授权的集中管理
a.单体架构
认证和授权逻辑集中在一个地方,管理和维护相对简单。
b.微服务架构
需要一个集中管理认证和授权的机制,例如 OAuth2 或者 JWT(JSON Web Token)。
Spring Security 本身并不提供集中管理的功能,需要额外的组件(如 Spring Cloud Security)来实现。
c.跨服务的安全上下文传递
a.单体架构
安全上下文(如用户信息、权限)在应用内存中可以直接访问和传递。
b.微服务架构
安全上下文需要在服务之间传递,通常通过 JWT 或者其他令牌机制。这需要额外的配置和处理,Spring Security 需要与这些机制集成。
d.性能和可扩展性
a.单体架构
所有请求在一个应用中处理,Spring Security 的性能和扩展性问题相对容易管理。
b.微服务架构
每个服务都需要处理认证和授权,可能会导致性能瓶颈。需要考虑分布式缓存、令牌验证的性能优化等问题。
e.服务间通信的安全性
a.单体架构
内部调用不涉及网络通信,安全性问题较少。
b.微服务架构
服务间通信通常通过 HTTP 或者消息队列,需要确保通信的安全性。
Spring Security 需要与 API 网关、服务网格等组件集成,确保服务间通信的安全。
f.故障隔离和容错
a.单体架构
一个应用中的安全问题可能影响整个应用,但故障隔离和容错相对简单。
b.微服务架构
一个服务的安全问题可能影响其他服务,需要更复杂的故障隔离和容错机制。
Spring Security 需要与分布式追踪、熔断器等组件集成,确保系统的健壮性。
g.解决方案和建议
a.使用 API 网关
在微服务架构中,通常使用 API 网关来集中处理认证和授权。API 网关可以与 Spring Security 集成,统一管理安全策略。
b.使用 OAuth2 和 JWT
采用 OAuth2 和 JWT 进行分布式认证和授权,Spring Security 可以作为资源服务器,验证和解析 JWT。
c.Spring Cloud Security
利用 Spring Cloud Security 提供的工具和组件,简化微服务架构下的安全配置和管理。
3.6 [2]过滤器链:shiro
00.总结
a.身份验证过滤器
用于确定用户的身份,控制访问权限
b.授权过滤器
用于根据用户的角色或权限控制访问
c.端口和SSL过滤器
用于控制访问的端口和协议(HTTP/HTTPS)
d.特殊用途过滤器
noSessionCreation用于控制会话创建
01.身份验证相关
a.anon (AnonymousFilter)
a.功能
允许未认证的匿名用户访问指定的 URL
b.用途
用于开放资源的访问,不需要用户登录即可访问
b.authc (FormAuthenticationFilter)
a.功能
基于表单的身份过滤器
b.属性
usernameParam: 表单提交时用户名的参数名(默认为 "username")
passwordParam: 表单提交时密码的参数名(默认为 "password")
rememberMeParam: 表单提交时记住我功能的参数名(默认为 "rememberMe")
loginUrl: 登录页面的 URL(默认是 "/login.jsp")
successUrl: 登录成功后的跳转地址
failureKeyAttribute: 登录失败后错误信息存储的键(默认是 "shiroLoginFailure")
c.authcBasic (BasicHttpAuthenticationFilter)
a.功能
使用 Basic HTTP 认证进行身份验证
b.属性
applicationName: 弹出登录框显示的信息
d.logout (authc.LogoutFilter)
a.功能
处理用户注销
b.属性
redirectUrl: 注销成功后重定向的地址(默认是 "/")
e.user (UserFilter)
a.功能
要求用户已经登录或被记住才能访问
02.授权相关
a.roles (RolesAuthorizationFilter)
a.功能
基于角色的授权过滤器
b.属性
loginUrl: 登录页面的 URL
unauthorizedUrl: 无权访问时重定向的地址
c.示例
/admin/**=roles[admin] 表示只有拥有 "admin" 角色的用户可以访问 /admin/** 下的资源
b.perms (PermissionsAuthorizationFilter)
a.功能
基于权限的授权过滤器
b.用途
验证用户是否拥有指定的权限
c.示例
/user/**=perms["user:create"] 表示只有拥有 "user:create" 权限的用户可以访问 /user/** 下的资源
c.port (PortFilter)
a.功能
端口过滤器
b.属性
port: 允许访问的端口(默认是 80)
如果用户访问的 URL 端口不是指定的端口,将自动重定向到指定端口
c.用途
确保特定的请求通过指定的端口访问
d.rest (HttpMethodPermissionFilter)
a.功能
REST 风格的权限过滤器
b.用途
根据 HTTP 方法来进行权限控制
c.示例
/users=rest[user]
可以通过 user:read, user:create, user:update, user:delete 等权限控制不同的 HTTP 方法访问
e.ssl (SslFilter)
a.功能
SSL 过滤器
b.用途
确保请求通过 HTTPS 进行
c.属性
默认监听端口 443
其他端口处理方式与 port 过滤器类似
f.noSessionCreation (NoSessionCreationAuthorizationFilter)
a.功能
禁止创建会话
b.用途
确保请求在没有会话的情况下进行,适用于 RESTful 服务
3.7 [2]过滤器链:完整思路
01.shiro实现
a.项目结构
config: 配置类,包括Shiro配置、JWT配置等
controller: 控制器类,处理HTTP请求
filter: 自定义过滤器,如验证码验证过滤器、JWT验证过滤器等
handler: 自定义处理器,如认证成功处理器、认证失败处理器
service: 服务类,处理业务逻辑,如用户详情服务、验证码生成服务等
util: 工具类,如JWT工具类
entity: 实体类,定义用户、角色等数据模型
b.功能模块
a.验证码验证
验证码生成: 使用Kaptcha或其他库生成图形验证码
验证码验证过滤器: 在身份验证之前验证用户输入的验证码
b.JWT认证
JWT生成与解析: 使用JWT工具类生成和解析JWT令牌
JWT验证过滤器: 在请求进入时验证JWT令牌的有效性
c.自定义认证处理器
认证成功处理器: 自定义处理器,在用户成功登录后生成JWT令牌并返回给客户端
认证失败处理器: 自定义处理器,在登录失败时返回错误信息
d.记住我功能
自定义RememberMeManager: 实现自定义的“记住我”策略
e.注销功能
自定义LogoutFilter: 实现自定义的注销逻辑,如清除JWT令牌
f.异常处理
自定义异常处理: 处理安全异常并返回友好的错误信息
c.配置类
a.ShiroConfig
配置Shiro,包括自定义过滤器链、认证管理器、会话管理等
b.JwtConfig
配置JWT相关参数,如密钥、过期时间等
d.实现步骤
a.配置Shiro
在ShiroConfig中配置自定义过滤器链,添加验证码验证过滤器、JWT验证过滤器、自定义处理器等
b.实现验证码功能
创建验证码生成服务和验证码验证过滤器
c.实现JWT功能
创建JWT工具类和JWT验证过滤器
d.实现自定义处理器
实现自定义的认证成功和失败处理器
e.实现记住我功能
自定义RememberMeManager
f.实现注销功能
自定义LogoutFilter
g.实现异常处理
自定义异常处理逻辑
3.8 [2]过滤器链:代码实战
00.项目结构
a.内容
com.zazhi.shiro_demo
│── common
│ ├── Result
│ ├── JwtUtil
│
│── controller
│ ├── MyController
│
│── pojo
│ ├── User
│
│── service
│ ├── UserService
│
│── shiro
│ ├── ShiroConfig
│ ├── JwtFilter
│ ├── AccountRealm
│ ├── JwtToken
│ ├── GlobalExceptionHandler
│
│── ShiroDemoApplication
│
resources
│── application.yml
b.说明
common:通用工具类(JwtUtil)和返回结果封装(Result)
controller:MyController 处理请求
service:业务逻辑层(UserService)
shiro:Shiro 相关配置,包括 ShiroConfig、JwtFilter、AccountRealm、JwtToken 以及全局异常处理 GlobalExceptionHandler
resources:配置文件 application.yml
01.导入依赖
a.内容
<dependencies>
<dependency>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
<version>1.2</version>
</dependency>
<!-- web依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 单元测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<!-- Shiro -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-starter</artifactId>
<classifier>jakarta</classifier>
<version>2.0.1</version>
<exclusions>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-crypto-cipher</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-crypto-hash</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-web</artifactId>
<classifier>jakarta</classifier>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<classifier>jakarta</classifier>
<version>2.0.1</version>
</dependency>
<!--jwt-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<!--knife4j-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<!--lombok依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</dependency>
</dependencies>
b.说明
其中的shiro依赖的导入我参考别人的文章,这样可以兼容SpringBoot 3.x,具体原理我也不懂
02.添加Shiro配置类
a.内容
import jakarta.servlet.Filter;
import org.apache.shiro.mgt.DefaultSessionStorageEvaluator;
import org.apache.shiro.mgt.DefaultSubjectDAO;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.spring.web.config.DefaultShiroFilterChainDefinition;
import org.apache.shiro.spring.web.config.ShiroFilterChainDefinition;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(DefaultWebSecurityManager securityManager,
ShiroFilterChainDefinition shiroFilterChainDefinition,
JwtFilter jwtFilter) {
// ShiroFilterFactoryBean 用于配置 Shiro 的拦截器链,并与 SecurityManager 关联。
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 并将 JwtFilter 实例与之("jwt")关联。相当于给这个拦截器起了个名字
// JwtFilter 负责处理所有请求的 JWT 认证。
Map<String, Filter> filters = new HashMap<>();
filters.put("jwt", jwtFilter);
shiroFilterFactoryBean.setFilters(filters);
// setFilterChainDefinitionMap 用来定义 URL 路径与过滤器的映射关系。所有请求都会通过 jwt 过滤器进行身份验证。
shiroFilterFactoryBean.setFilterChainDefinitionMap(shiroFilterChainDefinition.getFilterChainMap());
return shiroFilterFactoryBean;
}
// shiroFilterChainDefinition 定义了 URL 路径与过滤器的映射规则。
// 在这个例子中,所有的请求 (/**) 都必须通过 jwt 过滤器进行身份验证
// 如果有其他请求需要不同的权限控制,可以在这个方法中进一步调整或添加不同的过滤规则。
@Bean
public ShiroFilterChainDefinition shiroFilterChainDefinition() {
DefaultShiroFilterChainDefinition chainDefinition = new DefaultShiroFilterChainDefinition();
chainDefinition.addPathDefinition("/**", "jwt");
return chainDefinition;
}
// 创建 SecurityManager 对象,并设置自定义的 AccountRealm 作为认证器。
@Bean
public DefaultWebSecurityManager defaultWebSecurityManager(AccountRealm accountRealm) {
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager(accountRealm);
// 关闭session
DefaultSubjectDAO defaultSubjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
defaultSubjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator);
defaultWebSecurityManager.setSubjectDAO(defaultSubjectDAO);
return defaultWebSecurityManager;
}
// 防止 Spring 将 JwtFilter 注册为全局过滤器
// 没有这个的话请求会被 JwtFilter 拦截两次
@Bean
public FilterRegistrationBean<Filter> registration(JwtFilter filter) {
FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<Filter>(filter);
registration.setEnabled(false);
return registration;
}
}
03.添加自定义Realm
a.内容
import com.zazhi.shiro_demo.common.JwtUtil;
import com.zazhi.shiro_demo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
@Slf4j
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
// 这个方法用于判断 AccountRealm 是否支持该类型的 Token。
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
// 用于授权
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String username = (String) principals.getPrimaryPrincipal();
// 这里查询数据库获取用户的角色和权限
// TODO: 这个例子中,我们模拟了从数据库中查询用户的角色和权限信息。
// 实际项目中,你需要根据业务逻辑从数据库中查询用户的角色和权限信息。
Set<String> roles = userService.findRolesByUsername(username); // 示例:{admin}
Set<String> permissions = userService.findPermissionsByUsername(username); // 示例:{"user:delete", "user:update"}
// 并使用 addRoles 和 addStringPermissions 方法将角色和权限添加到授权信息中。
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.setRoles(roles);
authorizationInfo.setStringPermissions(permissions);
return authorizationInfo;
}
// 用于验证用户身份
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken){
JwtToken token = (JwtToken)authenticationToken;
String jwtToken = (String) token.getPrincipal();
// 这里是真正验证 JwtToken 是否有效的地方
// 如果验证失败,抛出 AuthenticationException 异常。这个请求会被认定为未认证请求。后续返回给前端
Map<String, Object> map;
try {
map = JwtUtil.parseToken(jwtToken);
} catch (Exception e) {
throw new AuthenticationException("该token非法,可能被篡改或过期");
}
String username = (String)map.get("username");
// TODO:这里可以根据业务逻辑自定义验证逻辑
// 例如:1.根据用户名查询数据库,判断用户是否存在 2.判断用户状态是否被锁定等
return new SimpleAuthenticationInfo(username, jwtToken, getName());
}
}
04.实现JwtFilter类和JwtToken类
a.JwtFilter
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.web.filter.authc.AuthenticatingFilter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMethod;
@Slf4j
@Component
public class JwtFilter extends AuthenticatingFilter {
// 拦截请求之后,用于把令牌字符串封装成令牌对象
// 该方法用于从请求中获取 JWT 并将其封装为 JwtToken(自定义的 AuthenticationToken 类)。
@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String jwtToken = httpRequest.getHeader("Authorization");
if (!StringUtils.hasLength(jwtToken)) {
return null;
}
return new JwtToken(jwtToken);
}
// 该方法的作用是判断当前请求是否被允许访问。它主要用于检查某些条件,决定是否允许访问或是否跳过认证过程
// 他作用于 onAccessDenied 之前
// 如果请求满足某些条件(例如,isAccessAllowed 返回 true),Shiro 会跳过后续的认证步骤,允许请求继续。
// 如果返回 false,Shiro 会继续执行 onAccessDenied,进行认证或其他授权操作。
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
// 从请求头中获取 Token
HttpServletRequest httpRequest = (HttpServletRequest) request;
String jwtToken = httpRequest.getHeader("Authorization");
if (StringUtils.hasLength(jwtToken)) { // 若当前请求存在 Token,则执行登录操作
try {
log.info("请求路径 {} 开始认证, token: {}", httpRequest.getRequestURI(), jwtToken);
getSubject(request, response).login(new JwtToken(jwtToken));
log.info("{} 认证成功", httpRequest.getRequestURI());
} catch (AuthenticationException e) {
log.error("{} 认证失败", httpRequest.getRequestURI());
}
}
// 若当前请求不存在 Token,没有认证意愿,直接放行
// 例如,登录接口或者游客可访问的接口不需要 Token
return true;
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
return true;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
res.setHeader("Access-control-Allow-Origin", req.getHeader("Origin"));
res.setHeader("Access-control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
res.setHeader("Access-control-Allow-Headers", req.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (req.getMethod().equals(RequestMethod.OPTIONS.name())) {
res.setStatus(HttpStatus.OK.value());
// 返回true则继续执行拦截链,返回false则中断后续拦截,直接返回,option请求显然无需继续判断,直接返回
return false;
}
return super.preHandle(request, response);
}
}
b.JwtToken
import org.apache.shiro.authc.AuthenticationToken;
/**
* @author zazhi
* @date 2024/12/10
* @description: JwtToken
*/
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
05.实现Controller和Service
a.Controller
import com.zazhi.shiro_demo.common.Result;
import com.zazhi.shiro_demo.service.UserService;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author zazhi
* @date 2024/12/9
* @description:
*/
@Slf4j
@RestController()
@RequestMapping("/api")
@Tag(name = "MyController", description = "用户接口")
public class MyController {
@Autowired
UserService userService;
// 模拟登录
@GetMapping("/login")
public String login(String username, String password) {
return userService.login(username, password);
}
// 不需要认证就能访问
@GetMapping("/public")
public Result<String> pub() {
log.info("调用 pub");
return Result.success("公共页面");
}
// 需要「认证」才能访问
@RequiresAuthentication
@GetMapping("/profile")
public Result<String> profile() {
return Result.success("个人信息页面");
}
// 需要「认证」和「特定角色」才能访问
@RequiresAuthentication
@RequiresRoles("admin")
@GetMapping("/dashboard")
public Result<String> dashboard() {
log.info("调用 dashboard");
return Result.success("控制面板页面");
}
// 需要「认证」和「特定权限」才能访问
@RequiresAuthentication
@RequiresPermissions("view:dashboard")
@GetMapping("/viewDashboard")
public Result<String> viewDashboard() {
return Result.success("查看控制面板页面");
}
}
b.Service
import com.zazhi.shiro_demo.common.JwtUtil;
import org.springframework.stereotype.Service;
import java.util.Map;
import java.util.Set;
/**
* @author zazhi
* @date 2024/12/9
* @description: TODO
*/
@Service
public class UserService {
public String login(String username, String password) {
// 判断逻辑省略
return JwtUtil.genToken(Map.of("username", username));
}
public Set<String> findPermissionsByUsername(String username) {
return Set.of("user:delete", "user:update");
}
public Set<String> findRolesByUsername(String username) {
// 模拟从数据库中查询用户角色
if(username.equals("admin")){
return Set.of("admin");
}
return Set.of("user");
}
}
06.全局异常处理类
a.内容
import com.zazhi.shiro_demo.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.UnauthenticatedException;
import org.apache.shiro.authz.UnauthorizedException;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler{
/**
* 处理未认证异常
* @param e
* @return
*/
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthenticatedException.class)
public Result handleUnauthenticatedException(UnauthenticatedException e){
return Result.error("未认证或Token无效,请重新登录");
}
/**
* 处理未授权异常
* @param e
* @return
*/
@ResponseStatus(HttpStatus.FORBIDDEN)
@ExceptionHandler(UnauthorizedException.class)
public Result handleUnauthorizedException(UnauthorizedException e){
return Result.error("未授权");
}
/**
* 处理其他异常
* @param e
* @return
*/
@ExceptionHandler(Exception.class)
public Result handleException(Exception e){
log.info("Exception: ", e);
return Result.error(StringUtils.hasLength(e.getMessage())? e.getMessage():"操作失败");
}
}
07.其他类
a.统一返回结果类
import lombok.Data;
import java.io.Serializable;
/**
* 后端统一返回结果
* @param <T>
*/
@Data
public class Result<T> implements Serializable {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
public static <T> Result<T> success() {
Result<T> result = new Result<T>();
result.code = 1;
return result;
}
public static <T> Result<T> success(T object) {
Result<T> result = new Result<T>();
result.data = object;
result.code = 1;
return result;
}
public static <T> Result<T> error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}
}
b.JWT工具类
package com.zazhi.shiro_demo.common;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
private static final String KEY = "zazhi";
//接收业务数据,生成token并返回
public static String genToken(Map<String, Object> claims) {
return JWT.create()
.withClaim("claims", claims)
.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 7))
.sign(Algorithm.HMAC256(KEY));
}
//接收token,验证token,并返回业务数据
public static Map<String, Object> parseToken(String token) {
return JWT.require(Algorithm.HMAC256(KEY))
.build()
.verify(token)
.getClaim("claims")
.asMap();
}
// 验证token是否有效
public static boolean verifyToken(String token) {
try {
JWT.require(Algorithm.HMAC256(KEY))
.build()
.verify(token);
return true;
} catch (Exception e) {
return false;
}
}
}
08.SpringBoot启动
a.内容
在 application.yml 中配置一下knife4j
springdoc:
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
api-docs:
path: /v3/api-docs
group-configs:
- group: 'default'
paths-to-match: '/**'
packages-to-scan: com.zazhi.shiro_demo.controller
# knife4j的增强配置,不需要增强可以不配
knife4j:
enable: true
setting:
language: zh_cn
b.启动
在浏览器访问:localhost:8080/doc.html 就可以开始测试啦
3.9 [2]过滤器链:security
00.汇总
a.顺序
WebAsyncManagerIntegrationFilter:用于处理异步请求
SecurityContextPersistenceFilter:用于从Session中获取或存储SecurityContext
HeaderWriterFilter:用于写入HTTP响应头
CsrfFilter:用于处理跨站请求伪造(CSRF)
LogoutFilter:用于处理注销请求
UsernamePasswordAuthenticationFilter:用于处理基于用户名和密码的身份验证
ConcurrentSessionFilter:用于管理并发会话
BasicAuthenticationFilter:用于处理HTTP基本认证
RequestCacheAwareFilter:用于管理请求缓存
SecurityContextHolderAwareRequestFilter:用于将请求包装成一个安全感知的请求
AnonymousAuthenticationFilter:用于处理匿名用户的身份验证
SessionManagementFilter:用于管理会话
ExceptionTranslationFilter:用于处理安全异常
FilterSecurityInterceptor:用于执行访问决策
b.支持自定义
UsernamePasswordAuthenticationFilter:用于处理基于用户名和密码的身份验证。可以自定义以支持不同的认证方式或添加额外的认证逻辑
LogoutFilter:用于处理注销请求。可以自定义以实现自定义的注销逻辑或处理注销后的操作
CsrfFilter:用于处理跨站请求伪造(CSRF)。可以自定义以实现自定义的CSRF保护机制
AuthenticationSuccessHandler 和 AuthenticationFailureHandler:虽然不是过滤器,但与UsernamePasswordAuthenticationFilter相关联,用于自定义认证成功和失败后的处理逻辑
RememberMeAuthenticationFilter:用于处理“记住我”功能。可以自定义以实现不同的“记住我”策略
ExceptionTranslationFilter:用于处理安全异常。可以自定义以实现自定义的异常处理逻辑
SecurityContextPersistenceFilter:用于从Session中获取或存储SecurityContext。可以自定义以实现不同的安全上下文持久化策略
01.原理
a.ChannelProcessingFilter原理
检查请求协议是否符合安全要求,重定向到正确的协议
b.SecurityContextPersistenceFilter原理
在请求开始时加载SecurityContext,在请求结束时保存SecurityContext
c.ConcurrentSessionFilter原理
检查用户会话的并发性,防止同一用户在多个地方同时登录
d.认证过滤器原理
处理用户提交的认证信息,验证用户身份
e.SecurityContextHolderAwareRequestFilter原理
包装请求对象,提供安全功能支持
f.JaasApiIntegrationFilter原理
将JAAS认证信息集成到Spring Security上下文中
g.RememberMeAuthenticationFilter原理
通过“记住我”cookie自动认证用户
h.AnonymousAuthenticationFilter原理
为未认证用户提供匿名身份,确保安全上下文总是存在
i.ExceptionTranslationFilter原理
捕获并处理安全异常,返回适当的HTTP响应
j.FilterSecurityInterceptor原理
进行访问控制决策,保护Web资源
02.常用API
a.ChannelProcessingFilter API
ChannelProcessingFilter.doFilter()
b.SecurityContextPersistenceFilter API
SecurityContextPersistenceFilter.doFilter()
c.ConcurrentSessionFilter API
ConcurrentSessionFilter.doFilter()
d.认证过滤器 API
UsernamePasswordAuthenticationFilter.attemptAuthentication()
e.SecurityContextHolderAwareRequestFilter API
SecurityContextHolderAwareRequestFilter.doFilter()
f.JaasApiIntegrationFilter API
JaasApiIntegrationFilter.doFilter()
g.RememberMeAuthenticationFilter API
RememberMeAuthenticationFilter.doFilter()
h.AnonymousAuthenticationFilter API
AnonymousAuthenticationFilter.doFilter()
i.ExceptionTranslationFilter API
ExceptionTranslationFilter.doFilter()
j.FilterSecurityInterceptor API
FilterSecurityInterceptor.invoke()
03.使用步骤
a.ChannelProcessingFilter使用步骤
配置通道安全策略,指定哪些URL需要使用HTTPS
在过滤器链中添加ChannelProcessingFilter
b.SecurityContextPersistenceFilter使用步骤
配置SecurityContextRepository
在过滤器链中添加SecurityContextPersistenceFilter
c.ConcurrentSessionFilter使用步骤
配置会话管理策略,设置最大会话数
在过滤器链中添加ConcurrentSessionFilter
d.认证过滤器使用步骤
配置认证提供者和认证过滤器
在过滤器链中添加相应的认证过滤器
e.SecurityContextHolderAwareRequestFilter使用步骤
确保HttpServletRequest被正确包装
在过滤器链中添加SecurityContextHolderAwareRequestFilter
f.JaasApiIntegrationFilter使用步骤
配置JAAS认证模块
在过滤器链中添加JaasApiIntegrationFilter
g.RememberMeAuthenticationFilter使用步骤
配置“记住我”服务
在过滤器链中添加RememberMeAuthenticationFilter
h.AnonymousAuthenticationFilter使用步骤
配置匿名用户的角色和权限
在过滤器链中添加AnonymousAuthenticationFilter
i.ExceptionTranslationFilter使用步骤
配置异常处理策略
在过滤器链中添加ExceptionTranslationFilter
j.FilterSecurityInterceptor使用步骤
配置访问控制策略
在过滤器链中添加FilterSecurityInterceptor
04.每个场景对应的代码示例
a.ChannelProcessingFilter示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.requiresChannel()
.anyRequest()
.requiresSecure();
}
}
b.SecurityContextPersistenceFilter示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.securityContext()
.securityContextRepository(new HttpSessionSecurityContextRepository());
}
}
c.ConcurrentSessionFilter示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}
}
d.认证过滤器示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage("/login")
.permitAll();
}
}
e.SecurityContextHolderAwareRequestFilter示例
// 自动配置,无需手动添加
f.JaasApiIntegrationFilter示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.jaas();
}
}
g.RememberMeAuthenticationFilter示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.rememberMe()
.key("uniqueAndSecret");
}
}
h.AnonymousAuthenticationFilter示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.anonymous()
.authorities("ROLE_ANONYMOUS");
}
}
i.ExceptionTranslationFilter示例
// 自动配置,无需手动添加
j.FilterSecurityInterceptor示例
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated();
}
}
3.10 [2]过滤器链:完整思路
01.springsecurity实现
a.项目结构
config: 配置类,包括Spring Security配置、JWT配置等
controller: 控制器类,处理HTTP请求
filter: 自定义过滤器,如验证码验证过滤器、JWT验证过滤器等
handler: 自定义处理器,如认证成功处理器、认证失败处理器
service: 服务类,处理业务逻辑,如用户详情服务、验证码生成服务等
util: 工具类,如JWT工具类
entity: 实体类,定义用户、角色等数据模型
b.功能模块
a.验证码验证
验证码生成: 使用Kaptcha或其他库生成图形验证码
验证码验证过滤器: 在UsernamePasswordAuthenticationFilter之前验证用户输入的验证码
b.JWT认证
JWT生成与解析: 使用JWT工具类生成和解析JWT令牌
JWT验证过滤器: 在请求进入时验证JWT令牌的有效性
c.自定义认证处理器
a.认证成功处理器
自定义AuthenticationSuccessHandler,在用户成功登录后生成JWT令牌并返回给客户端
b.认证失败处理器
自定义AuthenticationFailureHandler,在登录失败时返回错误信息
d.记住我功能
自定义RememberMeAuthenticationFilter: 实现自定义的“记住我”策略
e.注销功能
自定义LogoutFilter: 实现自定义的注销逻辑,如清除JWT令牌
f.CSRF保护
自定义CsrfFilter: 实现自定义的CSRF保护机制
g.异常处理
自定义ExceptionTranslationFilter: 处理安全异常并返回友好的错误信息
h.安全上下文持久化
自定义SecurityContextPersistenceFilter: 实现不同的安全上下文持久化策略。
c.配置类
a.SecurityConfig
配置Spring Security,包括自定义过滤器链、认证管理器、会话管理等
b.JwtConfig
配置JWT相关参数,如密钥、过期时间等
d.实现步骤
a.配置Spring Security
在SecurityConfig中配置自定义过滤器链,添加验证码验证过滤器、JWT验证过滤器、自定义处理器等
b.实现验证码功能
创建验证码生成服务和验证码验证过滤器
c.实现JWT功能
创建JWT工具类和JWT验证过滤器
d.实现自定义处理器
实现自定义的认证成功和失败处理器
e.实现记住我功能
自定义RememberMeAuthenticationFilter
f.实现注销功能
自定义LogoutFilter
g.实现CSRF保护
自定义CsrfFilter
h.实现异常处理
自定义ExceptionTranslationFilter
i.实现安全上下文持久化
自定义SecurityContextPersistenceFilter
3.11 [3]权限注解:9个
00.汇总
permitAll() 允许所有用户访问该请求,不需要进行任何身份验证
denyAll() 拒绝所有用户访问该请求
anonymous() 允许匿名用户访问该请求
authenticated() 要求用户进行身份验证,但是不要求用户具有任何特定的角色
hasRole(String role) 要求用户具有特定的角色才能访问该请求
hasAnyRole(String... roles) 要求用户具有多个角色中的至少一个角色才能访问该请求
hasAuthority(String authority) 要求用户具有特定的权限才能访问该请求
hasAnyAuthority(String... authorities) 要求用户具有多个权限中的至少一个权限才能访问该请求
01.permitAll()
a.定义
允许所有用户访问该请求,不需要进行任何身份验证
b.原理
Spring Security 配置中,permitAll() 方法用于配置某个请求路径可以被所有用户访问,无论用户是否经过身份验证
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#permitAll()
d.使用步骤
1.配置 Spring Security
2.使用 permitAll() 方法指定允许所有用户访问的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll() // 允许所有用户访问 /public/** 路径
.anyRequest().authenticated();
}
}
02.denyAll()
a.定义
拒绝所有用户访问该请求
b.原理
Spring Security 配置中,denyAll() 方法用于配置某个请求路径禁止所有用户访问
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#denyAll()
d.使用步骤
1.配置 Spring Security
2.使用 denyAll() 方法指定禁止所有用户访问的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").denyAll() // 禁止所有用户访问 /admin/** 路径
.anyRequest().authenticated();
}
}
03.anonymous()
a.定义
允许匿名用户访问该请求
b.原理
Spring Security 配置中,anonymous() 方法用于配置某个请求路径可以被匿名用户访问
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#anonymous()
d.使用步骤
1.配置 Spring Security
2.使用 anonymous() 方法指定允许匿名用户访问的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/guest/**").anonymous() // 允许匿名用户访问 /guest/** 路径
.anyRequest().authenticated();
}
}
04.authenticated()
a.定义
要求用户进行身份验证,但是不要求用户具有任何特定的角色
b.原理
Spring Security 配置中,authenticated() 方法用于配置某个请求路径需要用户进行身份验证
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#authenticated()
d.使用步骤
1.配置 Spring Security
2.使用 authenticated() 方法指定需要身份验证的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/user/**").authenticated() // 需要身份验证访问 /user/** 路径
.anyRequest().permitAll();
}
}
05.hasRole(String role)
a.定义
要求用户具有特定的角色才能访问该请求
b.原理
Spring Security 配置中,hasRole() 方法用于配置某个请求路径需要用户具有特定的角色
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#hasRole(String role)
d.使用步骤
1.配置 Spring Security
2.使用 hasRole() 方法指定需要特定角色的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN") // 需要 ADMIN 角色访问 /admin/** 路径
.anyRequest().authenticated();
}
}
06.hasAnyRole(String... roles)
a.定义
要求用户具有多个角色中的至少一个角色才能访问该请求
b.原理
Spring Security 配置中,hasAnyRole() 方法用于配置某个请求路径需要用户具有多个角色中的至少一个角色
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#hasAnyRole(String... roles)
d.使用步骤
1.配置 Spring Security
2.使用 hasAnyRole() 方法指定需要多个角色中的至少一个角色的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/management/**").hasAnyRole("ADMIN", "MANAGER") // 需要 ADMIN 或 MANAGER 角色访问 /management/** 路径
.anyRequest().authenticated();
}
}
07.hasAuthority(String authority)
a.定义
要求用户具有特定的权限才能访问该请求
b.原理
Spring Security 配置中,hasAuthority() 方法用于配置某个请求路径需要用户具有特定的权限
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#hasAuthority(String authority)
d.使用步骤
1.配置 Spring Security
2.使用 hasAuthority() 方法指定需要特定权限的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/secure/**").hasAuthority("ROLE_USER") // 需要 ROLE_USER 权限访问 /secure/** 路径
.anyRequest().authenticated();
}
}
08.hasAnyAuthority(String... authorities)
a.定义
要求用户具有多个权限中的至少一个权限才能访问该请求
b.原理
Spring Security 配置中,hasAnyAuthority() 方法用于配置某个请求路径需要用户具有多个权限中的至少一个权限
c.常用API
HttpSecurity#authorizeRequests()
HttpSecurity#hasAnyAuthority(String... authorities)
d.使用步骤
1.配置 Spring Security
2.使用 hasAnyAuthority() 方法指定需要多个权限中的至少一个权限的请求路径
e.代码示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/data/**").hasAnyAuthority("ROLE_USER", "ROLE_ADMIN") // 需要 ROLE_USER 或 ROLE_ADMIN 权限访问 /data/** 路径
.anyRequest().authenticated();
}
}
3.12 [3]权限注解:hasRole、hasAuthority
00.总结
hasRole: 主要用于角色检查,角色名称会自动加上 ROLE_ 前缀
hasAuthority: 主要用于权限检查,权限名称不会自动加前缀,可以是任意字符串
01.hasRole
a.定义
要求用户具有特定的角色才能访问该请求
b.原理
Spring Security 中的角色通常以 ROLE_ 前缀表示
例如,如果你配置 hasRole("ADMIN"),Spring Security 实际上会检查用户是否具有 ROLE_ADMIN 权限
c.使用场景
适用于角色管理,通常用于检查用户是否具有某个角色
d.示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN") // 需要 ADMIN 角色访问 /admin/** 路径
.anyRequest().authenticated();
}
}
02.hasAuthority
a.定义
要求用户具有特定的权限才能访问该请求
b.原理
Spring Security 中的权限可以是任意字符串,不需要特定的前缀
例如,如果你配置 hasAuthority("ROLE_ADMIN"),Spring Security 会直接检查用户是否具有 ROLE_ADMIN 权限
c.使用场景
适用于权限管理,通常用于检查用户是否具有某个具体的权限
d.示例
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/secure/**").hasAuthority("ROLE_USER") // 需要 ROLE_USER 权限访问 /secure/** 路径
.anyRequest().authenticated();
}
}
3.13 [3]储存上下文:SecurityContextHolder
00.汇总
一个用于存储与当前线程关联的安全上下文的类
使用 ThreadLocal 机制来存储 SecurityContext
SecurityContext 包含了当前用户的认证信息,如 Authentication 对象
01.概述
a.定义
SecurityContextHolder是Spring Security框架中的一个核心类
用于存储和获取当前线程的安全上下文(SecurityContext)
安全上下文包含了认证信息(Authentication),如当前用户的身份和权限。
b.原理
a.线程本地存储
SecurityContextHolder使用线程本地存储(ThreadLocal)来保存安全上下文
这意味着每个线程都有自己的安全上下文副本
b.安全上下文
SecurityContext包含Authentication对象,代表当前用户的认证信息,包括用户名、密码和权限等
c.常用API
SecurityContextHolder.getContext():获取当前线程的SecurityContext
SecurityContextHolder.setContext(SecurityContext context):设置当前线程的SecurityContext
SecurityContextHolder.clearContext():清除当前线程的SecurityContext
02.使用步骤
a.获取安全上下文
使用SecurityContextHolder.getContext()获取当前线程的安全上下文
从安全上下文中获取Authentication对象
b.设置安全上下文
创建一个新的SecurityContext对象
使用SecurityContextHolder.setContext()设置新的安全上下文
c.清除安全上下文
使用SecurityContextHolder.clearContext()清除当前线程的安全上下文
03.每个场景对应的代码示例
a.获取当前用户信息
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
public class SecurityExample {
public void printCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.isAuthenticated()) {
Object principal = authentication.getPrincipal();
if (principal instanceof UserDetails) {
UserDetails userDetails = (UserDetails) principal;
System.out.println("Current user: " + userDetails.getUsername());
} else {
System.out.println("Current user: " + principal.toString());
}
}
}
}
b.手动设置安全上下文
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
public class SecurityExample {
public void setSecurityContext() {
SecurityContext context = SecurityContextHolder.createEmptyContext();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken("user", "password");
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}
}
c.清除安全上下文
import org.springframework.security.core.context.SecurityContextHolder;
public class SecurityExample {
public void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
}
4 SpringSecurity
4.1 [1]跨站请求:CSRF攻击
00.汇总
启用 CSRF 保护(Spring Security 默认启用)
在 Spring Security 配置类中配置 CSRF 保护
在表单中添加 CSRF 令牌,确保每个表单提交都包含 CSRF 令牌
在 AJAX 请求中添加 CSRF 令牌,确保每个 AJAX 请求都包含 CSRF 令牌
01.启用CSRF保护
Spring Security 默认启用了 CSRF 保护
如果你没有显式地禁用它,那么 CSRF 保护已经在你的应用程序中启用了
02.配置CSRF保护,可以通过 HttpSecurity 对象的 csrf() 方法来配置 CSRF 保护
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().and() // 启用 CSRF 保护
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}
03.在表单中添加CSRF令牌
a.使用Thymeleaf模板引擎,可以使用 th:action 和 th:method 属性自动添加 CSRF 令牌
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form th:action="@{/login}" method="post">
<div>
<label>Username:</label>
<input type="text" name="username"/>
</div>
<div>
<label>Password:</label>
<input type="password" name="password"/>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
</body>
</html>
b.使用JSP,可以手动添加CSRF令牌
<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form" %>
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form action="${pageContext.request.contextPath}/login" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
<div>
<label>Username:</label>
<input type="text" name="username"/>
</div>
<div>
<label>Password:</label>
<input type="password" name="password"/>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
</body>
</html>
04.在AJAX
a.请求中添加CSRF令牌
// 获取 CSRF 令牌
var csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
var csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
// 在 AJAX 请求中添加 CSRF 令牌
var xhr = new XMLHttpRequest();
xhr.open('POST', '/your-endpoint', true);
xhr.setRequestHeader(csrfHeader, csrfToken);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ your: 'data' }));
b.HTML中添加CSRF令牌的meta标签
<meta name="_csrf" content="${_csrf.token}"/>
<meta name="_csrf_header" content="${_csrf.headerName}"/>
4.2 [1]访问控制:基于角色
00.汇总
配置用户及其角色,可以通过内存中配置用户,也可以从数据库中加载用户及其角色
在Spring Security配置类中,使用HttpSecurity对象的authorizeRequests方法配置基于角色的访问控制
如果从数据库中加载用户信息,可以创建一个自定义的UserDetails类来封装用户信息
01.配置用户角色
a.内存中配置用户及其角色,使用InMemoryUserDetailsManager来配置用户及其角色
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(inMemoryUserDetailsService());
}
@Bean
public UserDetailsService inMemoryUserDetailsService() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
UserDetails admin = User.withDefaultPasswordEncoder()
.username("admin")
.password("admin")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user, admin);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}
b.从数据库中加载用户及其角色,创建一个自定义的UserDetailsService来加载用户及其角色
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username);
if (user == null) {
throw new UsernameNotFoundException("User not found");
}
return new CustomUserDetails(user);
}
}
02.配置基于角色的访问控制,可以使用HttpSecurity对象的authorizeRequests方法来配置基于角色的访问控制
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}
03.创建自定义的UserDetails类,如果从数据库中加载用户信息,可以创建一个自定义的UserDetails类来封装用户信息
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.stream.Collectors;
public class CustomUserDetails implements UserDetails {
private User user;
public CustomUserDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
4.3 [2]认证模式:自定义逻辑
00.汇总
创建自定义的 UserDetailsService,用于从数据库或其他数据源加载用户信息
创建自定义的 AuthenticationProvider,用于处理认证逻辑
在Spring Security配置中,将自定义的认证提供者添加到认证管理器中
01.创建自定义的用户详情服务(UserDetailsService),用于从数据库或其他数据源加载用户信息
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 在这里从数据库或其他数据源加载用户信息
// 例如:User user = userRepository.findByUsername(username);
// 如果用户不存在,抛出 UsernameNotFoundException
// if (user == null) {
// throw new UsernameNotFoundException("User not found");
// }
// 返回 UserDetails 对象
// return new CustomUserDetails(user);
// 示例代码,实际应用中应从数据库加载用户信息
if (!"user".equals(username)) {
throw new UsernameNotFoundException("User not found");
}
return org.springframework.security.core.userdetails.User
.withUsername("user")
.password("{noop}password") // {noop} 表示不使用加密
.roles("USER")
.build();
}
}
02.创建自定义的认证提供者(AuthenticationProvider),用于处理认证逻辑
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
@Autowired
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 在这里添加自定义的认证逻辑
if (!password.equals(userDetails.getPassword())) {
throw new BadCredentialsException("Invalid password");
}
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
03.在Spring Security配置中将自定义的认证提供者添加到认证管理器中
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationProvider customAuthenticationProvider;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(customAuthenticationProvider);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
4.4 [2]认证模式:用户名+密码
00.汇总
引入Spring Security依赖
配置用户详细信息服务(UserDetailsService),用于从数据库或其他数据源加载用户信息
配置Spring Security,设置认证管理器和HTTP安全
创建一个简单的登录页面,供用户输入用户名和密码进行登录
00.用户名+密码的认证流程
a.用户请求
用户通过表单提交用户名和密码
b.认证请求
Spring Security拦截请求,将用户名和密码封装成认证请求(Authentication)对象
c.身份验证
Spring Security使用认证管理器(AuthenticationManager)来验证用户的身份
它通常会调用用户详细信息服务(UserDetailsService)来获取用户的详细信息,然后进行验证
d.校验凭证
认证管理器使用配置的密码编码器(PasswordEncoder)来比较提交的密码与存储的密码是否匹配
e.认证成功或失败
a.成功
如果凭证匹配,Spring Security会生成一个认证对象(Authentication),并将其存储在安全上下文(SecurityContext)中
b.失败
如果凭证不匹配,认证失败,会返回一个错误响应
f.访问控制
一旦用户认证成功,Spring Security会允许用户访问受保护的资源,根据配置的权限进行进一步的访问控制
01.依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
02.配置用户详细信息服务(UserDetailsService),创建一个自定义的UserDetailsService,用于从数据库或其他数据源加载用户信息
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 在这里从数据库或其他数据源加载用户信息
// 例如:User user = userRepository.findByUsername(username);
// 如果用户不存在,抛出 UsernameNotFoundException
// if (user == null) {
// throw new UsernameNotFoundException("User not found");
// }
// 返回 UserDetails 对象
// return new CustomUserDetails(user);
// 示例代码,实际应用中应从数据库加载用户信息
if (!"user".equals(username)) {
throw new UsernameNotFoundException("User not found");
}
return org.springframework.security.core.userdetails.User
.withUsername("user")
.password("{noop}password") // {noop} 表示不使用加密
.roles("USER")
.build();
}
}
03.配置Spring Security,创建一个Spring Security配置类,配置认证管理器和HTTP安全
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
04.创建登录页面
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="post" action="/login">
<div>
<label>Username:</label>
<input type="text" name="username"/>
</div>
<div>
<label>Password:</label>
<input type="password" name="password"/>
</div>
<div>
<button type="submit">Login</button>
</div>
</form>
</body>
</html>
05.启动应用程序
启动Spring Boot应用程序,访问/login路径,输入用户名和密码进行登录
4.5 [3]验证码:图形
01.图形验证码
a.概述
在用户登录时,一般通过表单的方式进行登录都会要求用户输入验证码
Spring Security默认没有实现图形验证码的功能,所以需要我们自己实现
b.流程分析
前文中实现的用户名、密码登录是在 UsernamePasswordAuthenticationFilter 过滤器进行认证的
而图形验证码一般是在用户名、密码认证之前进行验证的
所以需要在 UsernamePasswordAuthenticationFilter 过滤器之前
添加一个自定义过滤器 ImageCodeValidateFilter用来校验用户输入的图形验证码是否正确。
自定义过滤器继承 OncePerRequestFilter 类,该类是 Spring 提供的在一次请求中只会调用一次的 filter
c.流程分析
自定义的过滤器 ImageCodeValidateFilter 首先会判断请求是否为 POST 方式的登录表单提交请求
如果是就将其拦截进行图形验证码校验。如果验证错误,会抛出自定义异常类对象 ValidateCodeException
该异常类需要继承 AuthenticationException 类
在自定义过滤器中,我们需要手动捕获自定义异常类对象,并将捕获到自定义异常类对象交给自定义失败处理器进行处理
02.Kaptcha使用
a.依赖
<dependency>
<groupId>com.github.penggle</groupId>
<artifactId>kaptcha</artifactId>
<version>2.3.2</version>
</dependency>
b.KaptchaConfig配置类
package com.example.config;
import com.google.code.kaptcha.Constants;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
/**
* 图形验证码的配置类
*/
@Configuration
public class KaptchaConfig {
@Bean
public DefaultKaptcha captchaProducer() {
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
Properties properties = new Properties();
// 是否有边框
properties.setProperty(Constants.KAPTCHA_BORDER, "yes");
// 边框颜色
properties.setProperty(Constants.KAPTCHA_BORDER_COLOR, "192,192,192");
// 验证码图片的宽和高
properties.setProperty(Constants.KAPTCHA_IMAGE_WIDTH, "110");
properties.setProperty(Constants.KAPTCHA_IMAGE_HEIGHT, "40");
// 验证码颜色
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_COLOR, "0,0,0");
// 验证码字体大小
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_FONT_SIZE, "32");
// 验证码生成几个字符
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_LENGTH, "4");
// 验证码随机字符库
properties.setProperty(Constants.KAPTCHA_TEXTPRODUCER_CHAR_STRING, "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYAZ");
// 验证码图片默认是有线条干扰的,我们设置成没有干扰
properties.setProperty(Constants.KAPTCHA_NOISE_IMPL, "com.google.code.kaptcha.impl.NoNoise");
Config config = new Config(properties);
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
c.创建验证码的实体类CheckCode
package com.example.entity;
import java.io.Serializable;
import java.time.LocalDateTime;
public class CheckCode implements Serializable {
private String code; // 验证码字符
private LocalDateTime expireTime; // 过期时间
/**
* @param code 验证码字符
* @param expireTime 过期时间,单位秒
*/
public CheckCode(String code, int expireTime) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireTime);
}
public CheckCode(String code) {
// 默认验证码 60 秒后过期
this(code, 60);
}
// 是否过期
public boolean isExpried() {
return this.expireTime.isBefore(LocalDateTime.now());
}
public String getCode() {
return this.code;
}
}
d.在 LoginController 中添加获取图形验证码的 Controller 方法
package com.example.constans;
public class Constants {
// Session 中存储图形验证码的属性名
public static final String KAPTCHA_SESSION_KEY = "KAPTCHA_SESSION_KEY";
}
@Controller
public class LoginController {
@Autowired
private DefaultKaptcha defaultKaptcha;
//...
@GetMapping("/code/image")
public void imageCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 创建验证码文本
String capText = defaultKaptcha.createText();
// 创建验证码图片
BufferedImage image = defaultKaptcha.createImage(capText);
// 将验证码文本放进 Session 中
CheckCode code = new CheckCode(capText);
request.getSession().setAttribute(Constants.KAPTCHA_SESSION_KEY, code);
// 将验证码图片返回,禁止验证码图片缓存
response.setHeader("Cache-Control", "no-store");
response.setHeader("Pragma", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("image/jpeg");
ImageIO.write(image, "jpg", response.getOutputStream());
}
}
e.在 login.html 中添加验证码功能
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h3>表单登录</h3>
<form method="post" th:action="@{/login/form}">
<input type="text" name="name" placeholder="用户名"><br>
<input type="password" name="pwd" placeholder="密码"><br>
<input name="imageCode" type="text" placeholder="验证码"><br>
<img th:onclick="this.src='/code/image?'+Math.random()" th:src="@{/code/image}" alt="验证码"/><br>
<div th:if="${param.error}">
<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
</div>
<button type="submit">登录</button>
</form>
</body>
</html>
f.更改安全配置类 SpringSecurityConfig,设置访问 /code/image 不需要任何权限
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
//...
}
//...
}
03.自定义验证码过滤器
a.创建自定义异常类 ValidateCodeException
package com.example.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 自定义验证码校验错误的异常类,继承 AuthenticationException
*/
public class ValidateCodeException extends AuthenticationException {
public ValidateCodeException(String msg, Throwable t) {
super(msg, t);
}
public ValidateCodeException(String msg) {
super(msg);
}
}
b.自定义图形验证码校验过滤器 ImageCodeValidateFilter
package com.example.config.security;
import com.example.constans.Constants;
import com.example.entity.CheckCode;
import com.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@Component
public class ImageCodeValidateFilter extends OncePerRequestFilter {
private String codeParamter = "imageCode"; // 前端输入的图形验证码参数名
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 非 POST 方式的表单提交请求不校验图形验证码
if ("/login/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
try {
// 校验图形验证码合法性
validate(request);
} catch (ValidateCodeException e) {
// 手动捕获图形验证码校验过程抛出的异常,将其传给失败处理器进行处理
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
// 放行请求,进入下一个过滤器
filterChain.doFilter(request, response);
}
// 判断验证码的合法性
private void validate(HttpServletRequest request) {
// 获取用户传入的图形验证码值
String requestCode = request.getParameter(this.codeParamter);
if(requestCode == null) {
requestCode = "";
}
requestCode = requestCode.trim();
// 获取 Session
HttpSession session = request.getSession();
// 获取存储在 Session 里的验证码值
CheckCode savedCode = (CheckCode) session.getAttribute(Constants.KAPTCHA_SESSION_KEY);
if (savedCode != null) {
// 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码
session.removeAttribute(Constants.KAPTCHA_SESSION_KEY);
}
// 校验出错,抛出异常
if (StringUtils.isBlank(requestCode)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if (savedCode == null) {
throw new ValidateCodeException("验证码不存在");
}
if (savedCode.isExpried()) {
throw new ValidateCodeException("验证码过期");
}
if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
throw new ValidateCodeException("验证码输入错误");
}
}
}
c.更改安全配置类 SpringSecurityConfig,将自定义过滤器添加到过滤器链中
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
}
//...
}
d.完整的安全配置类 SpringSecurityConfig
package com.example.config;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器
@Autowired
private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)
/**
* 密码编码器,密码不能明文存储
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
// 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
return new BCryptPasswordEncoder();
}
/**
* 定制用户认证管理器来实现用户认证
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 采用内存存储方式,用户认证信息存储在内存中
// auth.inMemoryAuthentication()
// .withUser("admin").password(passwordEncoder()
// .encode("123456")).roles("ROLE_ADMIN");
// 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
// 启动 form 表单登录
http.formLogin()
// 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
.loginPage("/login/page")
// 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
.loginProcessingUrl("/login/form")
// 设置登录表单中的用户名参数,默认为 username
.usernameParameter("name")
// 设置登录表单中的密码参数,默认为 password
.passwordParameter("pwd")
// 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
//.defaultSuccessUrl("/index")
// 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
//.failureUrl("/login/page?error");
// 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
// 使用自定义的认证成功和失败处理器
.successHandler(authenticationSuccessHandler)
.failureHandler(authenticationFailureHandler);
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
// 关闭 csrf 防护
http.csrf().disable();
// 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);
}
/**
* 定制一些全局性的安全配置,例如:不拦截静态资源的访问
*/
@Override
public void configure(WebSecurity web) throws Exception {
// 静态资源的访问不需要拦截,直接放行
web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
}
}
4.6 [3]验证码:短信
00.概述
在Spring Security中实现手机短信验证码登录需要自定义多个组件
包括验证码发送服务、验证码校验过滤器、认证过滤器、认证提供者等
01.模拟发送短信验证码
a.在UserMapper接口中添加根据手机号查询用户的方法
public interface UserMapper {
@Select("select * from user where mobile = #{mobile}")
User selectByMobile(String mobile);
}
b.创建UserService类,判断指定手机号是否存在
package com.example.service;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
/**
* 判断指定 mobile 是否存在
*/
public boolean isExistByMobile(String mobile) {
return userMapper.selectByMobile(mobile) != null;
}
}
c.创建MobileCodeSendService类,模拟手机短信验证码发送服务
package com.example.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class MobileCodeSendService {
/**
* 模拟发送手机短信验证码
*/
public void send(String mobile, String code) {
String sendContent = String.format("验证码为 %s,请勿泄露!", code);
log.info("向手机号 " + mobile + " 发送短信:" + sendContent);
}
}
d.在LoginController中添加手机短信验证码相关的Controller方法
public class Constants {
// Session 中存储手机短信验证码的属性名
public static final String MOBILE_SESSION_KEY = "MOBILE_SESSION_KEY";
}
@Controller
public class LoginController {
@Autowired
private MobileCodeSendService mobileCodeSendService; // 模拟手机短信验证码发送服务
@Autowired
private UserService userService;
@GetMapping("/mobile/page")
public String mobileLoginPage() { // 跳转到手机短信验证码登录页面
return "login-mobile";
}
@GetMapping("/code/mobile")
@ResponseBody
public Object sendMoblieCode(String mobile, HttpServletRequest request) {
// 随机生成一个 4 位的验证码
String code = RandomStringUtils.randomNumeric(4);
// 将手机验证码文本存储在 Session 中,设置过期时间为 10 * 60s
CheckCode mobileCode = new CheckCode(code, 10 * 60);
request.getSession().setAttribute(Constants.MOBILE_SESSION_KEY, mobileCode);
// 判断该手机号是否注册
if(!userService.isExistByMobile(mobile)) {
return new ResultData<>(1, "该手机号不存在!");
}
// 模拟发送手机短信验证码到指定用户手机
mobileCodeSendService.send(mobile, code);
return new ResultData<>(0, "发送成功!");
}
}
e.编写手机短信验证码登录页面login-mobile.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
<script src="https://s3.pstatp.com/cdn/expire-1-M/jquery/3.3.1/jquery.min.js"></script>
</head>
<body>
<form method="post" th:action="@{/mobile/form}">
<input id="mobile" name="mobile" type="text" placeholder="手机号码"><br>
<div>
<input name="mobileCode" type="text" placeholder="验证码">
<button type="button" id="sendCode">获取验证码</button>
</div>
<div th:if="${param.error}">
<span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
</div>
<button type="submit">登录</button>
</form>
<script>
// 获取手机短信验证码
$("#sendCode").click(function () {
var mobile = $('#mobile').val().trim();
if(mobile == '') {
alert("手机号不能为空");
return;
}
// /code/mobile?mobile=123123123
var url = "/code/mobile?mobile=" + mobile;
$.get(url, function(data){
alert(data.msg);
});
});
</script>
</body>
</html>
f.更改安全配置类SpringSecurityConfig,设置访问/mobile/page和/code/mobile不需要任何权限
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 开启基于 HTTP 请求访问控制
http.authorizeRequests()
// 以下访问不需要任何权限,任何人都可以访问
.antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
// 以下访问需要 ROLE_ADMIN 权限
.antMatchers("/admin/**").hasRole("ADMIN")
// 以下访问需要 ROLE_USER 权限
.antMatchers("/user/**").hasAuthority("ROLE_USER")
// 其它任何请求访问都需要先通过认证
.anyRequest().authenticated();
//...
}
//...
}
02.自定义认证流程配置
a.自定义短信验证码校验过滤器MobileValidateFilter
package com.example.config.security.mobile;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.constans.Constants;
import com.example.entity.CheckCode;
import com.example.exception.ValidateCodeException;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
/**
* 手机短信验证码校验
*/
@Component
public class MobileCodeValidateFilter extends OncePerRequestFilter {
private String codeParamter = "mobileCode"; // 前端输入的手机短信验证码参数名
@Autowired
private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 非 POST 方式的手机短信验证码提交请求不进行校验
if("/mobile/form".equals(request.getRequestURI()) && "POST".equals(request.getMethod())) {
try {
// 检验手机验证码的合法性
validate(request);
} catch (ValidateCodeException e) {
// 将异常交给自定义失败处理器进行处理
authenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
// 放行,进入下一个过滤器
filterChain.doFilter(request, response);
}
/**
* 检验用户输入的手机验证码的合法性
*/
private void validate(HttpServletRequest request) {
// 获取用户传入的手机验证码值
String requestCode = request.getParameter(this.codeParamter);
if(requestCode == null) {
requestCode = "";
}
requestCode = requestCode.trim();
// 获取 Session
HttpSession session = request.getSession();
// 获取 Session 中存储的手机短信验证码
CheckCode savedCode = (CheckCode) session.getAttribute(Constants.MOBILE_SESSION_KEY);
if (savedCode != null) {
// 随手清除验证码,无论是失败,还是成功。客户端应在登录失败时刷新验证码
session.removeAttribute(Constants.MOBILE_SESSION_KEY);
}
// 校验出错,抛出异常
if (StringUtils.isBlank(requestCode)) {
throw new ValidateCodeException("验证码的值不能为空");
}
if (savedCode == null) {
throw new ValidateCodeException("验证码不存在");
}
if (savedCode.isExpried()) {
throw new ValidateCodeException("验证码过期");
}
if (!requestCode.equalsIgnoreCase(savedCode.getCode())) {
throw new ValidateCodeException("验证码输入错误");
}
}
}
b.更改自定义失败处理器CustomAuthenticationFailureHandler
package com.example.config.security;
import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 继承 SimpleUrlAuthenticationFailureHandler 处理器,该类是 failureUrl() 方法使用的认证失败处理器
*/
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
String xRequestedWith = request.getHeader("x-requested-with");
// 判断前端的请求是否为 ajax 请求
if ("XMLHttpRequest".equals(xRequestedWith)) {
// 认证成功,响应 JSON 数据
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(1, "认证失败!")));
}else {
// 用户名、密码方式登录出现认证异常,需要重定向到 /login/page?error
// 手机短信验证码方式登录出现认证异常,需要重定向到 /mobile/page?error
// 使用 Referer 获取当前登录表单提交请求是从哪个登录页面(/login/page 或 /mobile/page)链接过来的
String refer = request.getHeader("Referer");
String lastUrl = StringUtils.substringBefore(refer, "?");
// 设置默认的重定向路径
super.setDefaultFailureUrl(lastUrl + "?error");
// 调用父类的 onAuthenticationFailure() 方法
super.onAuthenticationFailure(request, response, e);
}
}
}
c.自定义短信验证码认证过滤器MobileAuthenticationFilter
package com.example.config.security.mobile;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 手机短信验证码认证过滤器,仿照 UsernamePasswordAuthenticationFilter 过滤器编写
*/
public class MobileAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private String mobileParamter = "mobile"; // 默认手机号参数名为 mobile
private boolean postOnly = true; // 默认请求方式只能为 POST
protected MobileAuthenticationFilter() {
// 默认登录表单提交路径为 /mobile/form,POST 方式请求
super(new AntPathRequestMatcher("/mobile/form", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
//(1) 默认情况下,如果请求方式不是 POST,会抛出异常
if(postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}else {
//(2) 获取请求携带的 mobile
String mobile = request.getParameter(mobileParamter);
if(mobile == null) {
mobile = "";
}
mobile = mobile.trim();
//(3) 使用前端传入的 mobile 构造 Authentication 对象,标记该对象未认证
// MobileAuthenticationToken 是我们自定义的 Authentication 类,后续介绍
MobileAuthenticationToken authRequest = new MobileAuthenticationToken(mobile);
//(4) 将请求中的一些属性信息设置到 Authentication 对象中,如:remoteAddress,sessionId
this.setDetails(request, authRequest);
//(5) 调用 ProviderManager 类的 authenticate() 方法进行身份认证
return this.getAuthenticationManager().authenticate(authRequest);
}
}
@Nullable
protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(this.mobileParamter);
}
protected void setDetails(HttpServletRequest request, MobileAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
public void setMobileParameter(String mobileParamter) {
Assert.hasText(mobileParamter, "Mobile par ameter must not be empty or null");
this.mobileParamter = mobileParamter;
}
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public String getMobileParameter() {
return mobileParamter;
}
}
d.自定义用户信息封装类MobileAuthenticationToken
package com.example.config.security.mobile;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
public class MobileAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 520L;
private final Object principal;
/**
* 认证前,使用该构造器进行封装信息
*/
public MobileAuthenticationToken(Object principal) {
super((Collection) null); // 用户权限为 null
this.principal = principal; // 前端传入的手机号
this.setAuthenticated(false); // 标记为未认证
}
/**
* 认证成功后,使用该构造器封装用户信息
*/
public MobileAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities); // 用户权限集合
this.principal = principal; // 封装认证用户信息的 UserDetails 对象,不再是手机号
super.setAuthenticated(true); // 标记认证成功
}
@Override
public Object getCredentials() {
// 由于使用手机短信验证码登录不需要密码,所以直接返回 null
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
} else {
super.setAuthenticated(false);
}
}
@Override
public void eraseCredentials() {
// 手机短信验证码认证方式不必去除额外的敏感信息,所以直接调用父类方法
super.eraseCredentials();
}
}
e.自定义短信验证码认证的处理器MobileAuthenticationProvider
package com.example.config.security.mobile;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.Assert;
public class MobileAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private UserDetailsChecker authenticationChecks = new MobileAuthenticationProvider.DefaultAuthenticationChecks();
/**
* 处理认证
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//(1) 如果入参的 Authentication 类型不是 MobileAuthenticationToken,抛出异常
Assert.isInstanceOf(MobileAuthenticationToken.class, authentication, () -> {
return this.messages.getMessage("MobileAuthenticationProvider.onlySupports", "Only MobileAuthenticationToken is supported");
});
// 获取手机号
String mobile = authentication.getPrincipal() == null ? "NONE_PROVIDED" : authentication.getName();
//(2) 根据手机号从数据库中查询用户信息
UserDetails user = this.userDetailsService.loadUserByUsername(mobile);
if (user == null) {
//(3) 未查询到用户信息,抛出异常
throw new AuthenticationServiceException("该手机号未注册");
}
//(4) 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期
this.authenticationChecks.check(user);
//(5) 查询到了用户信息,则认证通过,构建标记认证成功用户信息类对象 AuthenticationToken
MobileAuthenticationToken result = new MobileAuthenticationToken(user, user.getAuthorities());
// 需要把认证前 Authentication 对象中的 details 信息加入认证后的 Authentication
result.setDetails(authentication.getDetails());
return result;
}
/**
* ProviderManager 管理器通过此方法来判断是否采用此 AuthenticationProvider 类
* 来处理由 AuthenticationFilter 过滤器传入的 Authentication 对象
*/
@Override
public boolean supports(Class<?> authentication) {
// isAssignableFrom 返回 true 当且仅当调用者为父类.class,参数为本身或者其子类.class
// ProviderManager 会获取 MobileAuthenticationFilter 过滤器传入的 Authentication 类型
// 所以当且仅当 authentication 的类型为 MobileAuthenticationToken 才返回 true
return MobileAuthenticationToken.class.isAssignableFrom(authentication);
}
/**
* 此处传入自定义的 MobileUserDetailsSevice 对象
*/
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
/**
* 检查账号是否锁定、账号是否可用、账号是否过期、密码是否过期
*/
private class DefaultAuthenticationChecks implements UserDetailsChecker {
private DefaultAuthenticationChecks() {
}
@Override
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
throw new LockedException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
} else if (!user.isEnabled()) {
throw new DisabledException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
} else if (!user.isAccountNonExpired()) {
throw new AccountExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
} else if (!user.isCredentialsNonExpired()) {
throw new CredentialsExpiredException(MobileAuthenticationProvider.this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired", "User credentials have expired"));
}
}
}
}
f.自定义MobileUserDetailsService类
package com.example.service;
import com.example.entity.User;
import com.example.mapper.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class MobileUserDetailsService implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String mobile) throws UsernameNotFoundException {
//(1) 从数据库尝试读取该用户
User user = userMapper.selectByMobile(mobile);
// 用户不存在,抛出异常
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
//(2) 将数据库形式的 roles 解析为 UserDetails 的权限集合
// AuthorityUtils.commaSeparatedStringToAuthorityList() 是 Spring Security 提供的方法,用于将逗号隔开的权限集字符串切割为可用权限对象列表
user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
//(3) 返回 UserDetails 对象
return user;
}
}
g.自定义短信验证码认证方式配置类MobileAuthenticationConfig
package com.example.config.security.mobile;
import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.service.MobileUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.stereotype.Component;
@Component
public class MobileAuthenticationConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; // 自定义认证成功处理器
@Autowired
private CustomAuthenticationFailureHandler customAuthenticationFailureHandler; // 自定义认证失败处理器
@Autowired
private MobileCodeValidateFilter mobileCodeValidaterFilter; // 手机短信验证码校验过滤器
@Autowired
private MobileUserDetailsService userDetailsService; // 手机短信验证方式的 UserDetail
@Override
public void configure(HttpSecurity http) throws Exception {
//(1) 将短信验证码认证的自定义过滤器绑定到 HttpSecurity 中
//(1.1) 创建手机短信验证码认证过滤器的实例 filer
MobileAuthenticationFilter filter = new MobileAuthenticationFilter();
//(1.2) 设置 filter 使用 AuthenticationManager(ProviderManager 接口实现类) 认证管理器
// 多种登录方式应该使用同一个认证管理器实例,所以获取 Spring 容器中已经存在的 AuthenticationManager 实例
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
filter.setAuthenticationManager(authenticationManager);
//(1.3) 设置 filter 使用自定义成功和失败处理器
filter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
filter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
//(1.4) 设置 filter 使用 SessionAuthenticationStrategy 会话管理器
// 多种登录方式应该使用同一个会话管理器实例,获取 Spring 容器已经存在的 SessionAuthenticationStrategy 实例
SessionAuthenticationStrategy sessionAuthenticationStrategy = http.getSharedObject(SessionAuthenticationStrategy.class);
filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
//(1.5) 在 UsernamePasswordAuthenticationFilter 过滤器之前添加 MobileCodeValidateFilter 过滤器
// 在 UsernamePasswordAuthenticationFilter 过滤器之后添加 MobileAuthenticationFilter 过滤器
http.addFilterBefore(mobileCodeValidaterFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterAfter(filter, UsernamePasswordAuthenticationFilter.class);
//(2) 将自定义的 MobileAuthenticationProvider 处理器绑定到 HttpSecurity 中
//(2.1) 创建手机短信验证码认证过滤器的 AuthenticationProvider 实例,并指定所使用的 UserDetailsService
MobileAuthenticationProvider provider = new MobileAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
//(2.2) 将该 AuthenticationProvider 实例绑定到 HttpSecurity 中
http.authenticationProvider(provider);
}
}
h.将自定义配置类MobileAuthenticationConfig绑定到安全配置类SpringSecurityConfig中
@EnableWebSecurity // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
//...
@Autowired
private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类
//...
/**
* 定制基于 HTTP 请求的用户访问控制
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
//...
// 将手机短信验证码认证的配置与当前的配置绑定
http.apply(mobileAuthenticationConfig);
}
//...
}
i.测试
访问 `localhost:8080/mobile/page`,输入手机号,获取验证码并进行登录测试
4.7 [4]登录认证:记住我
00.汇总
在 Spring Security 配置类中启用“记住我”功能
选择使用内存或数据库存储“记住我”令牌
配置“记住我”功能的参数,如 cookie 名称和有效期
01.定义
允许用户在关闭浏览器后仍然保持登录状态
通过在用户登录时选择“记住我”选项,用户可以在一段时间内自动登录,而不需要再次输入用户名和密码
02.原理
通过在客户端存储一个持久化的 cookie 来实现,这个 cookie 包含一个唯一的令牌,用于标识用户,服务器端会保存这个令牌与用户信息的映射关系
当用户再次访问应用时,Spring Security 会检查这个 cookie,如果存在且有效,则自动为用户进行身份验证
03.常用API
HttpSecurity.rememberMe(): 配置“记住我”功能
RememberMeServices: 处理“记住我”功能的接口
PersistentTokenRepository: 用于持久化“记住我”令牌的接口
04.使用步骤
配置“记住我”功能:在 Spring Security 配置类中启用“记住我”功能
设置令牌存储:选择使用内存存储或数据库存储“记住我”令牌
自定义“记住我”参数(可选):配置 cookie 名称、有效期等参数
05.代码示例
a.示例1:使用内存存储“记住我”令牌
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.rememberMe() // 启用“记住我”功能
.key("uniqueAndSecret") // 设置一个唯一的密钥
.tokenValiditySeconds(86400) // 设置令牌有效期(秒)
.and()
.logout()
.permitAll();
}
}
b.示例2:使用数据库存储“记住我”令牌
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import javax.sql.DataSource;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final DataSource dataSource;
public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.rememberMe() // 启用“记住我”功能
.tokenRepository(persistentTokenRepository()) // 使用数据库存储令牌
.tokenValiditySeconds(86400) // 设置令牌有效期(秒)
.and()
.logout()
.permitAll();
}
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
}
4.8 [4]登录认证:注销登录
00.汇总
在 Spring Security 配置类中启用并配置注销功能
设置注销请求路径(如 /logout)
配置注销成功后的重定向路径或自定义处理逻辑
01.定义
允许用户安全地退出应用程序,清除用户的认证信息和会话数据,确保用户的会话被正确终止
02.原理
注销登录通过 Spring Security 提供的 LogoutFilter 实现
当用户请求注销时,LogoutFilter 会处理该请求,清除用户的认证信息和会话数据
并执行任何配置的注销处理逻辑(如重定向到登录页面或显示注销成功消息)
03.常用API
HttpSecurity.logout(): 配置注销登录功能
LogoutSuccessHandler: 自定义注销成功处理逻辑的接口
SecurityContextLogoutHandler: 清除用户认证信息的默认处理器
04.使用步骤
配置注销登录功能:在 Spring Security 配置类中启用并配置注销登录功能
自定义注销行为(可选):配置注销成功后的重定向或其他处理逻辑
定义注销请求路径:设置用户触发注销的请求路径
05.代码示例
a.示例1:基本的注销配置
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout() // 启用注销功能
.logoutUrl("/logout") // 设置注销请求路径
.logoutSuccessUrl("/login?logout") // 注销成功后重定向到登录页面
.permitAll();
}
}
b.示例2:自定义注销成功处理,如果需要在注销成功后执行自定义逻辑,可以实现 LogoutSuccessHandler 接口
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler(new CustomLogoutSuccessHandler()) // 使用自定义注销成功处理器
.permitAll();
}
public class CustomLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response,
org.springframework.security.core.Authentication authentication) throws IOException {
// 自定义注销成功后的处理逻辑
response.sendRedirect("/custom-logout-success");
}
}
}
4.9 [4]登录处理器:成功+失败
00.汇总
认证成功处理器:实现AuthenticationSuccessHandler接口
认证失败处理器:实现AuthenticationFailureHandler接口
01.认证成功处理器:实现AuthenticationSuccessHandler
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 自定义登录成功后的处理逻辑
System.out.println("登录成功,用户:" + authentication.getName());
response.sendRedirect("/home"); // 重定向到主页
}
}
02.认证失败处理器:实现AuthenticationFailureHandler接口
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
// 自定义登录失败后的处理逻辑
System.out.println("登录失败,错误信息:" + exception.getMessage());
response.sendRedirect("/login?error=true"); // 重定向到登录页面并显示错误信息
}
}
03.在Spring Security配置中使用自定义处理器
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.successHandler(new CustomAuthenticationSuccessHandler()) // 使用自定义认证成功处理器
.failureHandler(new CustomAuthenticationFailureHandler()) // 使用自定义认证失败处理器
.permitAll()
.and()
.logout()
.permitAll();
}
}