之所以想写这一系列,是因为之前工作过程中使用Spring Security,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0,关键是其风格和内部一些关键Filter大改,导致在配置同样功能时,多费了些手脚,因此花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。
注意:由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,所有代码都在spring-security-study项目上:https://github.com/forever1986/spring-security-study.git
在前面我们已经将Spring Security的2大模块功能做了入门演示。这一章我们来看看Spring Security实现前后端分离。现在的项目基本上都是前后端分离,中间数据交付采用json格式,那么Spring Security做前后端分离需要做哪些内容。
1 Spring Security前后端分离
Spring Security做前后端分离,其实有两种方式:
- 一种是利用原先UsernamePasswordAuthenticationFilter,继承该类,然后从request中获取用户名方式不一样
- 一种是重新定义自己的登录,屏蔽掉原先的登录
我们这里采用屏蔽原先的登录,采用自定义登录接口。我们先来理清楚前后端分离需要理清楚哪些内容。
- 第一:既然是前后端分离,那么我们需要定义2个接口,一个是登录接口,一个是登出接口;用于替换原先页面登录
- 第二:我们需要屏蔽到Spring Security的login:formLogin(AbstractHttpConfigurer::disable),也就屏蔽了默认登录界面
- 第三:由于屏蔽了Spring Security的login,我们会发现原来做认证的UsernamePasswordAuthenticationFilter、DefaultLoginPageGeneratingFilter和DefaultLogoutPageGeneratingFilter均不在FilteChain中,因此我们要自己实现登录逻辑,而Spring Security的登录逻辑我们在系列二中已经讲过。
- 第四:通过模拟UsernamePasswordAuthenticationFilter的attemptAuthentication方法,通过AuthenticationManager认证并存入SecurityContex即可
2 代码实现
代码参考lesson08子模块
注意:本项目lesson08子模块复制了lesson03子模块,使用其自定义的基于数据库获取用户信息,利用的还是spring_security_study数据库的t_user表
1)新建lesson08子模块,其pom文件如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Spring Boot 提供的 Security 启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
</dependencies>
2)拷贝lesson03子模块的代码到lesson08子模块
3)新建package为result的包,在下面现在3个类,主要用于处理返回值
public interface IResultCode {
String getCode();
String getMsg();
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Result<T> implements Serializable {
private String code;
private T data;
private String msg;
private long total;
public static <T> Result<T> success() {
return success((T)null);
}
public static <T> Result<T> success(T data) {
ResultCode rce = ResultCode.SUCCESS;
if (data instanceof Boolean && Boolean.FALSE.equals(data)) {
rce = ResultCode.SYSTEM_EXECUTION_ERROR;
}
return result(rce, data);
}
public static <T> Result<T> success(T data, Long total) {
Result<T> result = new Result();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMsg(ResultCode.SUCCESS.getMsg());
result.setData(data);
result.setTotal(total);
return result;
}
public static <T> Result<T> failed() {
return result(ResultCode.SYSTEM_EXECUTION_ERROR.getCode(), ResultCode.SYSTEM_EXECUTION_ERROR.getMsg(), (T)null);
}
public static <T> Result<T> failed(String msg) {
return result(ResultCode.SYSTEM_EXECUTION_ERROR.getCode(), msg, (T)null);
}
public static <T> Result<T> judge(boolean status) {
return status ? success() : failed();
}
public static <T> Result<T> failed(IResultCode resultCode) {
return result(resultCode.getCode(), resultCode.getMsg(), (T)null);
}
public static <T> Result<T> failed(IResultCode resultCode, String msg) {
return result(resultCode.getCode(), msg, (T)null);
}
private static <T> Result<T> result(IResultCode resultCode, T data) {
return result(resultCode.getCode(), resultCode.getMsg(), data);
}
private static <T> Result<T> result(String code, String msg, T data) {
Result<T> result = new Result();
result.setCode(code);
result.setData(data);
result.setMsg(msg);
return result;
}
public static boolean isSuccess(Result<?> result) {
return result != null && ResultCode.SUCCESS.getCode().equals(result.getCode());
}
public Result() {
}
public String getCode() {
return this.code;
}
public T getData() {
return this.data;
}
public String getMsg() {
return this.msg;
}
public long getTotal() {
return this.total;
}
public void setCode(String code) {
this.code = code;
}
public void setData(T data) {
this.data = data;
}
public void setMsg(String msg) {
this.msg = msg;
}
public void setTotal(long total) {
this.total = total;
}
public boolean equals(Object o) {
if (o == this) {
return true;
} else if (!(o instanceof Result)) {
return false;
} else {
Result<?> other = (Result)o;
if (!other.canEqual(this)) {
return false;
} else if (this.getTotal() != other.getTotal()) {
return false;
} else {
label49: {
Object this$code = this.getCode();
Object other$code = other.getCode();
if (this$code == null) {
if (other$code == null) {
break label49;
}
} else if (this$code.equals(other$code)) {
break label49;
}
return false;
}
Object this$data = this.getData();
Object other$data = other.getData();
if (this$data == null) {
if (other$data != null) {
return false;
}
} else if (!this$data.equals(other$data)) {
return false;
}
Object this$msg = this.getMsg();
Object other$msg = other.getMsg();
if (this$msg == null) {
if (other$msg != null) {
return false;
}
} else if (!this$msg.equals(other$msg)) {
return false;
}
return true;
}
}
}
protected boolean canEqual(Object other) {
return other instanceof Result;
}
public int hashCode() {
int result = 1;
long $total = this.getTotal();
result = result * 59 + (int)($total >>> 32 ^ $total);
Object $code = this.getCode();
result = result * 59 + ($code == null ? 43 : $code.hashCode());
Object $data = this.getData();
result = result * 59 + ($data == null ? 43 : $data.hashCode());
Object $msg = this.getMsg();
result = result * 59 + ($msg == null ? 43 : $msg.hashCode());
return result;
}
public String toString() {
return "Result(code=" + this.getCode() + ", data=" + this.getData() + ", msg=" + this.getMsg() + ", total=" + this.getTotal() + ")";
}
}
public enum ResultCode implements IResultCode, Serializable {
SUCCESS("00000", "ok"),
USER_ERROR("A0001", "用户信息为空"),
PARAM_IS_NULL("A0410", "请求必填参数为空"),
SYSTEM_EXECUTION_ERROR("B0001", "系统执行出错");
private String code;
private String msg;
public String getCode() {
return this.code;
}
public String getMsg() {
return this.msg;
}
public String toString() {
return "{\"code\":\"" + this.code + '"' + ", \"msg\":\"" + this.msg + '"' + '}';
}
public static ResultCode getValue(String code) {
ResultCode[] var1 = values();
int var2 = var1.length;
for(int var3 = 0; var3 < var2; ++var3) {
ResultCode value = var1[var3];
if (value.getCode().equals(code)) {
return value;
}
}
return SYSTEM_EXECUTION_ERROR;
}
private ResultCode(String code, String msg) {
this.code = code;
this.msg = msg;
}
private ResultCode() {
}
}
4)在entity包下新建一个LoginDTO,用于前后端传输用户名密码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginDTO {
private String username;
private String password;
}
5)在service包下新建LoginService,用于处理登录和登出
@Service
public class LoginService {
// 注入AuthenticationManagerBuilder,用于获得authenticationManager
@Autowired
private AuthenticationManagerBuilder authenticationManagerBuilder;
public Result<String> login(LoginDTO loginDTO, HttpServletRequest request, HttpServletResponse response) {
// 拿到前端传过来的用户名密码
String username = loginDTO.getUsername();
String password = loginDTO.getPassword();
// 构建一个AuthenticationToken
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
try {
// 以下部分是因为我们屏蔽原先密码登录,自己实现密码登录,因此要模仿UsernamePasswordAuthenticationFilter处理流程
// 使用authenticationManager认证
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 成功返回登录成功
if(authentication!=null && authentication.isAuthenticated()){
// 保存用户信息
SecurityContextHolder.getContext().setAuthentication(authentication);
// 设置session
HttpSession session = request.getSession();
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, SecurityContextHolder.getContext());
return Result.success("登录成功");
}
}catch (AuthenticationException e){
// 返回认证失败信息
return Result.failed(e.getLocalizedMessage());
}
catch (Exception e){
e.printStackTrace();
return Result.failed("未知错误");
}
return Result.failed("认证失败");
}
public Result<String> logout() {
// 判断SecurityContext
if(SecurityContextHolder.getContext().getAuthentication()!=null){
SecurityContextHolder.getContext().setAuthentication(null);
// 清楚SecurityContext
SecurityContextHolder.clearContext();
return Result.success("登出成功");
}else{
return Result.failed("登出失败,用户不存在");
}
}
}
6)在controller包下新建LoginController,定义2个接口,用于登录和登出
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
@PostMapping("/login")
public Result<String> login(@RequestBody LoginDTO loginDTO, HttpServletRequest request, HttpServletResponse response) {
return loginService.login(loginDTO, request, response);
}
@PostMapping("/logout")
public Result<String> logout() {
return loginService.logout();
}
}
7)新建config包,在该包下新建SecurityConfig配置,用于自定义Spring Security的配置
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 所有访问都必须认证
.authorizeHttpRequests(auth->auth
// 允许"/login","/logout"免登录访问
.requestMatchers("/login","/logout").permitAll()
// 其它接口需要登录访问
.anyRequest().authenticated())
// 自定义登录界面,并允许无需认证即可访问
.formLogin(AbstractHttpConfigurer::disable)
// 关闭默认的登出
.logout(LogoutConfigurer::disable)
// 关闭csrf功能,因为我们有post请求,而csrf会屏蔽post请求
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
8)使用postman访问:http://127.0.0.1:8080/demo ,这时候出现无权限访问
9)使用postman访问:http://127.0.0.1:8080/login ,显示登录成功
10)再次使用postman访问:http://127.0.0.1:8080/demo ,则可以访问接口
11)同样,我们可以测试:http://127.0.0.1:8080/logout ,登出后,再次访问demo接口,就会出现无法访问
大家可以看看按照我们目前配置其Filter过滤器的情况,时刻关注过滤器,可以当我们很敏感捕获到出现意想不到的的情况时,很有可能是因为某个过滤器引起的,下图就是目前配置下过滤器情况
结语:我们现在通过自定义方式实现了真正的前后端分离,但是项目还是基于Session方式,那么下一章我们会更贴近实际,使用JWT,将结合Redis、JWT、异常处理,实现一个前后端分离的完整示例