Bootstrap

Spring Security 6 系列之八 - 前后端分离

之所以想写这一系列,是因为之前工作过程中使用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、异常处理,实现一个前后端分离的完整示例

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;