一、前后端分离的认证面对的问题
1.1 传统的前后端不分离认证特点
- 传统前后端不分离项目,Spring Security认证通常基于会话(session-based authentication),即用户登录成功后,服务器会在服务器端创建一个用户会话,并生成一个唯一的会话ID(JSESSIONID),然后通过Cookie发送给客户端。客户端的每次请求都会携带这个会话ID,服务器通过该ID在内存中查找用户的会话信息。这种方式的核心在于服务器要维护用户的会话状态,因此属于有状态认证。
- 从身份认证流程上来说,用户通过浏览器访问服务器渲染的登录页面,提交用户名和密码后,Spring Security会处理表单请求(POST /login)。登录成功后,服务器会自动重定向到一个页面或返回相应的视图(例如首页),整个过程通过HTML表单、会话和页面跳转来完成。服务器不仅处理业务逻辑,还负责渲染页面【采用了模板引擎的项目】。
- 跨域问题, 由于前端和后端在同一个服务器上运行,通常不会涉及跨域问题
- 登录和登出的机制, 用户登录后,Spring Security会在服务器端维护会话,用户注销时,后端通过销毁会话来完成登出操作。这个过程通过Spring Security自带的表单登录机制进行。登出后,通常会通过服务器端重定向到登录页面或其他页面。
- CSRF(跨站请求伪造)防护上, Spring Security默认启用了CSRF防护机制。在基于会话的认证中,服务器通过生成CSRF token并将其嵌入到HTML表单中,来防止CSRF攻击。当用户提交表单时,服务器验证CSRF token是否正确。
- 登录成功或失败后,服务器通常会返回一个完整的HTML页面(或通过重定向实现),用户可以看到对应的视图。所有页面跳转和导航由后端控制,通过服务器端渲染实现页面切换.
- 用户信息存储去传递, 用户信息通常存储在服务器的会话中,浏览器的每次请求都会自动携带会话ID,通过该ID服务器可以找到用户的身份信息。
1.2 现代的前后端分离认证特点
- 在前后端分离的项目中,通常使用JWT(JSON Web Token)或其他Token-based认证(如OAuth2)。用户登录成功后,服务器会生成一个Token(通常是JWT),客户端在每次请求时将Token放入HTTP请求头中(如Authorization: Bearer ),然后服务器通过解析Token来验证用户身份。这种方式不依赖于服务器的会话机制,服务器不会存储用户的状态信息(无状态认证),因此更适合前后端分离的场景。
- 在前后端分离项目中,前端与后端之间通过API进行交互。用户提交用户名和密码时,通常会通过Ajax调用一个API端点(例如POST /api/login),Spring Security在后端验证后返回一个Token(例如JWT)。
- 客户端(通常是单页应用,如React或Vue.js)会将Token保存在浏览器的localStorage或sessionStorage中,并在后续的请求中将其附加到HTTP头部。后端API通过验证Token来识别用户。
- 前后端分离的项目通常不会依赖于服务器端的页面重定向,而是由前端框架自行控制页面跳转。
- 前后端分离项目中,前端和后端通常运行在不同的域名或端口下,导致浏览器的同源策略限制。为了允许跨域请求,必须配置CORS(Cross-Origin Resource Sharing)策略。Spring Security需要通过配置允许特定的域名进行跨域访问,尤其是登录和获取资源的请求。
- 用户登录后,Token会被保存在客户端(例如localStorage或sessionStorage)。登出时,客户端只需要删除存储的Token,并通知后端(如果需要),可以通过API让后端使Token失效(如Token黑名单)或删除服务器端的相关记录(如刷新Token)。
- 由于Token-based认证(如JWT)使用的是无状态的HTTP头部认证,不依赖于Cookie和会话,因此通常不需要CSRF防护。可以通过禁用Spring Security的CSRF防护(http.csrf().disable())来适配前后端分离的项目。
- 登录成功后,后端不会返回HTML页面,而是返回一个Token(例如JWT)或JSON格式的用户信息,前端通过处理API返回的结果进行页面的跳转和状态管理。
- 所有的页面跳转和视图渲染由前端框架(如React、Vue、Angular)处理,后端只负责返回数据而不关心页面渲染。
- 用户信息通常通过Token(如JWT)在客户端存储。Token中包含了用户的身份信息(如sub,exp等字段),后端通过解析Token来验证用户身份。Token无需存储在服务器,客户端携带Token的每次请求可以在无状态下进行身份认证。
1.3 前后端分离认证需要的准备工作
- 前端工作, 采用vue3进行开发一个简单的页面
- 认证凭证JWT,这里不做详细介绍,直接使用,默认已经熟悉
- 跨域处理,直接在后端配置即可
- 自定义认证流程逻辑编写
- 测试
二、前端端分离认证实操
2.1 前端工程准备
- 创建vue3 工程,别太复杂了.够用就行了.
- 引入路由组件、Axios、Element Plus 按照官方文档配置
- 相应的工程目录如下所示
一共两个路由组件,登录和主页.效果展示:
目前没有登录逻辑,点击登录,直接进入主页,之后会在这里进行登录的认证请求.
具体内容填充,之后RBAC的时候再填充相应的内容.
2.2 搭建后端工程
- JDK17
- Spring boot 3.3.4
2.2.1 引入JWT工具类
引入jwt需要的依赖,这里要注意,由于我们使用的是JDK 17,所以必须得引入JWT兼容高版本的依赖.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<!--解决高版本JDK问题-->
<!--javax.xml.bind.DatatypeConverter错误-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
JWT工具类封装,你也可以自己找找,实现功能即可.这玩意一找一大大堆
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import java.util.Date;
import java.util.UUID;
public class JwtUtil {
/**
* jwt过期时间
*/
public static final Long EXP_TTL = 60 * 60 * 1000L;
/**
* jwt使用的密钥
*/
public static final String JWT_KEY = "c3R1ZHkgaGFyZCBhbmQgbWFrZSBwcm9ncmVzcyBldmVyeSBkYXku";
/**
* 创建jwt字符串
* @param id id
* @param issuer 创建的作者
* @param subject 用户主体
* @param ttlMillis 过期时间, 毫秒值
* @return jwt字符串
*/
public static String createJWT(String id, String issuer, String subject, long ttlMillis) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(JWT_KEY);
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
JwtBuilder builder = Jwts
.builder()
.setId(id)
.setIssuedAt(now)
.setSubject(subject)
.setIssuer(issuer)
.signWith(signingKey, signatureAlgorithm);
if (ttlMillis >= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
return builder.compact();
}
/**
* 创建jwt字符串
* @param issuer 作者信息
* @param subject 用户主体信息
* @param ttlMillis 过期时间, 毫秒值
* @return jwt字符串
*/
public static String createJwt(String issuer, String subject, long ttlMillis){
return createJWT(uuid(), issuer, subject, ttlMillis);
}
/**
* 创建jwt字符串
* @param id 作者信息
* @param subject 用户主体信息
* @return jwt字符串
*/
public static String createJwt(String id, String subject){
return createJWT(id, "yyds", subject, EXP_TTL);
}
/**
* 创建jwt字符串
* @param subject 用户主体
* @return jwt字符串
*/
public static String createJwt(String subject){
return createJwt("rj", subject, EXP_TTL);
}
/**
* uuid
* @return String
*/
private static String uuid(){
return UUID.randomUUID().toString().replaceAll("-", "");
}
/**
* 解析jwt
* @param jwt jwt字符串
* @return Claims
*/
public static Claims parseJWT(String jwt) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(DatatypeConverter.parseBase64Binary(JWT_KEY))
.build()
.parseClaimsJws(jwt).getBody();
return claims;
}
/**
* 判断token是否过期
* @param token
* @return
*/
public static boolean isTokenExpired(String token){
Claims claims = parseJWT(token);
return new Date(System.currentTimeMillis()).after(claims.getExpiration());
}
public static void main(String[] args) {
String jwt = createJWT("1024", "rj", "rj", EXP_TTL);
System.out.println(jwt);
Claims claims = parseJWT(jwt);
Object subject = claims.get("sub");
System.out.println(subject);
System.out.println(claims);
System.out.println(claims.getSubject());
System.out.println(claims.getIssuedAt());
System.out.println(claims.getExpiration());
}
执行一下main方法,验证一下工具类是否可以正常工作,另外这个工具类还可以扩展一下,将密钥、过期时间做成配置文件更好一些.
2.2.2 跨域处理
解决跨域的问题的方式有很多种,这里咱们简单粗暴,在服务器端处理一下即可
@Configuration
public class GlobalCorsConfig {
@Bean
public CorsFilter corsWebFilter() {
CorsConfiguration config = new CorsConfiguration();
// 这里仅为了说明问题,配置为放行所有域名,生产环境请对此进行修改
config.addAllowedOriginPattern("*");
// 放行的请求头
config.addAllowedHeader("*");
// 放行的请求类型,有 GET, POST, PUT, DELETE, OPTIONS
config.addAllowedMethod("*");
// 暴露头部信息
config.addExposedHeader("*");
// 是否允许发送 Cookie
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
注意导包别导错了:
- import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
- import org.springframework.web.filter.CorsFilter;
2.2.3 封装实体类
这里需要引入lombok
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SysUser {
private String username;
private String password;
}
2.2.4 统一结果返回工具类
后端返回给前端的数据,应该是统一格式,方便前端、后端处理,这里简单封装一下. 这里找你自己喜欢的就行,无所谓的.
@Data
public class Result implements Serializable {
private static final long serialVersionUID = -3657911743622479730L;
private int code;
private String msg;
private Object data;
private Result(int code, String msg, Object data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public static Result success(int code, String msg, Object data){
return new Result(code, msg, data);
}
public static Result success(String msg, Object data){
return success(0, msg, data);
}
public static Result success(String msg){
return success(0, msg, null);
}
public static Result error(int code, String msg, Object data){
return new Result(code, msg, data);
}
public static Result error(int code, String msg){
return error(code, msg, null);
}
}
2.2.5 工程结构
2.3 测试前后端通信
前端点击登录按钮, 发送请求: localhost:9527/api/pub/v1/login, 在后端写一下登录接口,测试一下,看看能否正常通信.
返回数据
前端登录处理示例代码
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
// 发起请求
login(ruleForm).then((res) => {
if (res.code === 0) {
// 存储一下token
sessionStorage.setItem('token', res.data.token)
// 路由跳转
router.push({
name: 'home'
})
}else {
ElMessage({
type: 'error',
message: res.msg,
showClose: true,
})
}
}).catch(error => {
ElMessage.error(error)
})
} else {
console.log('error submit!')
}
})
}
所以的工作都准备结束,下边开始实现一下自定义的认证逻辑
2.4 自定义认证整体流程分析
有了流程之后,我们就按照这个流程实现代码即可,接口我们处理完成,下边处理service层业务
2.4.1 认证核心业务逻辑
回顾一下默认的认证流程,看如下代码, 我们可以根据默认的流程,自己写认证流程.
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
- 封装用户名称和密码,
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
其中username和password我们工程当中,由前端直接传递过来.然后封装成UsernamePasswordAuthenticationToken对象即可.
- 使用认证管理器,调用认证方法
this.getAuthenticationManager().authenticate(authRequest);
我们需要解决的是自己构建一个AuthenticationManager对象,我们可以在spring security配置文件当中进行配置
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
综上所述我们就可以写自己的逻辑了.在service层,实现核心业务
@Service
public class SysUserServiceImpl implements ISysUserService {
private final AuthenticationManager authenticationManager;
public SysUserServiceImpl(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@Override
public Result login(SysUser sysUser) {
String username = sysUser.getUsername();
String password = sysUser.getPassword();
if(!StringUtils.hasText(username)){
return Result.error(-1, "用户名称不能为空");
}
if(!StringUtils.hasText(password)){
return Result.error(-2, "用户密码不能为空");
}
// 封装请求参数
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
= new UsernamePasswordAuthenticationToken(username, password);
// 手动调用认证方法
// 如果没有抛出异常,则表示认证成功,则返回一个完整对象,我们从中获取封装的UserDetails对象
try {
Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
// 获取认证对象
User user = (User) authenticate.getPrincipal();
// 生成jwt
String id = UUID.randomUUID().toString().replace("-", "");
String token = JwtUtil.createJwt(id, user.getUsername());
return Result.success(0, "登录成功", token);
}catch (Exception e){
e.printStackTrace();
}
return Result.error(-1, "用户名称或者密码错误");
}
}
核心代码都添加注释了,自行查阅.
自己实现UserDetailsService接口, 这个先模拟操作一下,代码如下所示
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名称去数据当中查询出用户信息,这里还是先模拟一下
String name = "admin";
String password = "admin";
// 如果根据用户名称没有查询到到用户信息,则抛出异常,这里模拟操作
// 如果没有问题,则将用户信息封装成UserDetails对象
return new User(name, password, Collections.emptyList());
}
}
根据咱们的画的流程图,我们的核心业务已经完成,下边测试一下.
2.4.2 测试
返回服务器控制台,查看日志
PasswordEncoder是 Spring Security 中用于对用户密码进行加密和验证的接口。SpringSecurity默认对用户密码是加密的处理,我们必须得配置一个加密算法.其主要作用是
- 密码加密:
- 在用户注册或创建账户时,PasswordEncoder可以将用户输入的明文密码进行加密处理,然后将加密后的密码存储在数据库中。这样可以防止密码以明文形式存储,提高系统的安全性。
- 常见的加密算法有 BCrypt、SHA-256 等。不同的加密算法具有不同的强度和特点。
- 密码验证:
- 在用户登录时,PasswordEncoder可以将用户输入的密码与存储在数据库中的加密密码进行比较和验证。它会使用相同的加密算法对用户输入的密码进行加密,然后与存储的加密密码进行匹配。
- 如果匹配成功,则用户身份验证通过;否则,验证失败。
常用的加密器BCryptPasswordEncoder, 这是一种比较常用的密码编码器,它使用 BCrypt 哈希算法对密码进行加密。BCrypt 算法具有较高的安全性,因为它会自动生成随机的盐值(salt),并将盐值与密码一起进行加密。这样即使两个用户使用相同的密码,加密后的结果也会不同。
所以我们在配置文件当中配置PasswordEncoder即可.
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
其次,我们在数据库存储的密码应该使用BCryptPasswordEncoder进行加密处理,才能匹配上验证密码的算法.对默认的密码
admin处理一下
@Test
public void passwordEncoder() {
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String pwd = bCryptPasswordEncoder.encode("admin");
System.out.println(pwd);
}
在UserDetailsServiceImpl当中修改一下
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据用户名称去数据当中查询出用户信息,这里还是先模拟一下
String name = "admin";
// String password = "admin";
String password = "$2a$10$4/S6K/z/nF5eTk9KlF/PgOGtv2jlLGrzpO3oXINQAkNNlMqtVT6ru";
// 如果根据用户名称没有查询到到用户信息,则抛出异常,这里模拟操作
// 如果没有问题,则将用户信息封装成UserDetails对象
return new User(name, password, Collections.emptyList());
}
再次测试
至此, 整个认证流程完成一半了,剩下一半就是当认证成功之后,再次请求服务器接口,对token的校验了.我们放在下篇完成它.
2.5 完整配置文件
@Configuration
public class SpringSecurityConfig {
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.authorizeHttpRequests(authorize -> {
try {
authorize.requestMatchers("/api/pub/v1/login").permitAll()
.requestMatchers("/static/**", "/resources/**").permitAll()
.anyRequest().authenticated();
} catch (Exception e) {
throw new RuntimeException(e);
}
}).csrf(AbstractHttpConfigurer::disable)
.build();
}
}
三、总结
3.1 重点内容
- 前后端分离认证流程分析
- 整个认证流程处理
- PasswordEncoder
3.2 下篇内容
- 验证token,完善自定义认证流程