如何自己封装一个 Spring Boot 登录 Starter?通用认证流程与扩展点设计实战

本篇文章的目标读者:想理解 Spring Boot Starter 的开发方式、想把认证登录能力(或其他通用能力)组件化的同学。

那我们为什么要写一个登录的 Starter 呢?

Starter 其实就是一个快速的启动器,帮你快速接入某一类功能的一组依赖和默认配置入口。通过引入 Starter 后,我们就不需要单个引入一堆依赖了。

如果你的公司有好多系统,你肯定不想为每个系统都写一套 /auth/login、JWT 生成、JWT 刷新、密码校验、登录态解析,你说可以直接复制过去,但每个系统的用户表可能不一样、密码比较规则可能也不一样、每个系统的前置后置逻辑可能也不一样、token续期逻辑可能也不一样,所以我们要把这一套固定流程沉淀成 starter,把变化点开放成扩展接口。

本篇文章就带大家从零搭建一个Spring Boot Starter 的登录组件,当然,大家在后续业务开发中可以完全使用这个框架抽象其他的通用能力进行封装。

1.Starter项目结构

我们可以参考springboot的开发方式,主要分为4层结构

1.auth-login-core

这个模块主要放认证领域模型、登录主流程、业务扩展接口等,比如一些用的的实体、dto、主要实现、接口定义。

2.auth-login-spring-boot-autoconfigure

这个模块主要放自动配置和真正开箱即用的能力,一些配置类的能力、过滤器、AuthAutoConfiguration、JwtTokenService等。

3.auth-login-spring-boot-starter

这个模块是给业务方引入的依赖入口。它本身几乎不写业务代码,只负责聚合依赖。比如我们可以直接在业务项目中引入(springboot就是这么搞的)。

4.auth-login-demo

这个模块主要用来演示业务项目如何接入自定义 starter 、实现auth-login-core中的接口,上面我们提到了auth-login-spring-boot-starter这个模块不写任何业务代码,主要是为了让业务方直接引用,比如:

<dependency>
            <groupId>com.jag.auth</groupId>
            <artifactId>auth-login-spring-boot-starter</artifactId>
            <version>${project.version}</version>
        </dependency>

2.明确边界,starter 负责什么

在登录的场景下,starter主要负责: /auth/login、/auth/refresh、JWT accessToken / refreshToken 生成、登录主流程编排、自动配置与过滤器等。

业务系统主要负责:根据用户名查询自己系统的用户、如果密码规则特殊,自己实现 PasswordMatcher 进行密码比对、可选前置校验,比如验证码、租户校验、可选登录成功失败后的审计处理等。

3.项目结构拆解

直接上图吧,比较清晰(绿色部分是需要我们业务实现的,黄色部分是组件实现的)

在这里插入图片描述

4.核心设计:模板方法 + 策略模式 + 扩展点模式

4.1.auth-login-core 模块中的核心实现

对于登录逻辑,我们要制定一套登录的主流程,也就是定义个模版。

这套流程主要功能为:前置校验、查找用户、校验密码、校验状态、生成 token、返回响应。这个流程固定死,不要直接让用户去实现,用户可按需求实现其中某个细节。 这就是模版方法模式/策略模式 / 扩展点模式。

登录模版实现细节 (我把注释放到了代码段里 方便理解)

public class DefaultLoginService implements LoginService {

    private final List<LoginPreChecker> preCheckers;
    private final AuthUserLoader authUserLoader;
    private final PasswordMatcher passwordMatcher;
    private final UserStatusChecker userStatusChecker;
    private final TokenService tokenService;
    private final List<LoginSuccessHandler> successHandlers;
    private final List<LoginFailureHandler> failureHandlers;

  	// 构造方法注入实例
    public DefaultLoginService(List<LoginPreChecker> preCheckers,
                               AuthUserLoader authUserLoader,
                               PasswordMatcher passwordMatcher,
                               UserStatusChecker userStatusChecker,
                               TokenService tokenService,
                               List<LoginSuccessHandler> successHandlers,
                               List<LoginFailureHandler> failureHandlers) {
        this.preCheckers = preCheckers;
        this.authUserLoader = authUserLoader;
        this.passwordMatcher = passwordMatcher;
        this.userStatusChecker = userStatusChecker;
        this.tokenService = tokenService;
        this.successHandlers = successHandlers;
        this.failureHandlers = failureHandlers;
    }

    @Override
    public LoginResponse login(LoginRequest request) {
        try {
          	// 1.前置检查:遍历并且执行业务实现的 LoginPreChecker
            for (LoginPreChecker preChecker : preCheckers) {
                preChecker.check(request);
            }
						// 2.查找用户:获取业务系统用户,业务系统需实现这个方法
            AuthUser user = authUserLoader.loadByUsername(request.getUsername());
            if (user == null) {
                throw new AuthException("USER_NOT_FOUND", "User does not exist");
            }
						// 3.校验密码:业务系统实现 PasswordMatcher 可以定义自己的密码校验逻辑
            if (!passwordMatcher.matches(request.getPassword(), user.getPasswordHash())) {
                throw new AuthException("BAD_CREDENTIALS", "Username or password is incorrect");
            }
						// 4.校验状态:业务系统可以实现此方法校验用户状态等
            userStatusChecker.check(user);
          
          	// 5.生成token:token生成的逻辑一般不需要业务系统实现
            TokenPair tokenPair = tokenService.generate(user);
          
						// 6.成功处理器:比如可以通过实现 LoginSuccessHandler 来记录登录成功日志
            for (LoginSuccessHandler successHandler : successHandlers) {
                successHandler.onSuccess(user, tokenPair);
            }
            return LoginResponse.from(user, tokenPair);
        } catch (Exception ex) {
          	// 7.失败处理器:比如可以通过实现 LoginFailureHandler 来记录异常信息
            for (LoginFailureHandler failureHandler : failureHandlers) {
                failureHandler.onFailure(request, ex);
            }
            throw ex;
        }
    }

    @Override
    public TokenPair refreshToken(String refreshToken) {
        return tokenService.refresh(refreshToken);
    }
}

我把上述核心的业务扩展接口列一下,以下接口均可提供默认实现或业务实现。

// 查找用户
public interface AuthUserLoader {
    AuthUser loadByUsername(String username);
}
// 失败处理器
public interface LoginFailureHandler {
    void onFailure(LoginRequest request, Exception ex);
}
// 前置检查
public interface LoginPreChecker {
    void check(LoginRequest request);
}
// 成功处理器
public interface LoginSuccessHandler {
    void onSuccess(AuthUser user, TokenPair tokenPair);
}
// 校验密码
public interface PasswordMatcher {
    boolean matches(String rawPassword, String encodedPassword);
}
// 校验状态
public interface UserStatusChecker {
    void check(AuthUser user);
}

4.2.auth-login-spring-boot-autoconfigure 模块中核心实现

这个模块主要提供登录入口、自动配置、初始化 Bean、Jwt 实现、过滤器等,这里主要对自动配置和初始化 Bean 做下说明。

首先为了让 Spring Boot 知道要去加载我们的 AuthAutoConfiguration ,需要在 META-INF/spring.factories 里声明自动配置类。

例如:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.jag.auth.autoconfigure.config.AuthAutoConfiguration

然后我们需要定义一个 AuthAutoConfiguration 来初始化 Bean,并且通过 @ConditionalOnMissingBean 注解提供默认实现,业务系统如需覆盖直接创建对应实现类即可

@Configuration
@EnableConfigurationProperties(AuthProperties.class)
@ConditionalOnClass(HttpSecurity.class)
public class AuthAutoConfiguration {
    @Bean
    @ConditionalOnMissingBean
    public PasswordMatcher passwordMatcher() {
        return new BCryptPasswordMatcher();
    }
    @Bean
    @ConditionalOnMissingBean
    public UserStatusChecker userStatusChecker() {
        return new DefaultUserStatusChecker();
    }
    @Bean
    @ConditionalOnMissingBean
    public TokenService tokenService(AuthProperties authProperties) {
        return new JwtTokenService(authProperties);
    }
    @Bean
    @ConditionalOnMissingBean
    public LoginService loginService(ObjectProvider<List<LoginPreChecker>> preCheckersProvider,
                                     AuthUserLoader authUserLoader,
                                     PasswordMatcher passwordMatcher,
                                     UserStatusChecker userStatusChecker,
                                     TokenService tokenService,
                                     ObjectProvider<List<LoginSuccessHandler>> successHandlersProvider,
                                     ObjectProvider<List<LoginFailureHandler>> failureHandlersProvider) {
        return new DefaultLoginService(
                preCheckersProvider.getIfAvailable(Collections::emptyList),
                authUserLoader,
                passwordMatcher,
                userStatusChecker,
                tokenService,
                successHandlersProvider.getIfAvailable(Collections::emptyList),
                failureHandlersProvider.getIfAvailable(Collections::emptyList)
        );
    }
    @Bean
    @ConditionalOnMissingBean
    public JwtAuthenticationFilter jwtAuthenticationFilter(TokenService tokenService, AuthProperties authProperties) {
        return new JwtAuthenticationFilter(tokenService, authProperties);
    }
    @Bean
    @ConditionalOnMissingBean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize -> authorize
                        .antMatchers("/auth/login", "/auth/refresh").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(Customizer.withDefaults());
        return http.build();
    }
    @Bean
    @ConditionalOnMissingBean
    public AuthController authController(LoginService loginService) {
        return new AuthController(loginService);
    }
    @Bean
    @ConditionalOnMissingBean
    public AuthExceptionHandler authExceptionHandler() {
        return new AuthExceptionHandler();
    }
}

4.3.了解 @ConditionalOnMissingBean 注解

用 PasswordMatcher 这个接口类举例,正常我们组件里已经实现了一套默认的密码比较逻辑,那业务系统想覆盖我们的逻辑,是如何实现的呢?

就是通过 @ConditionalOnMissingBean 注解,过程就是:

  1. starter 先声明了一个默认的 PasswordMatcher
  2. 但它加了 @ConditionalOnMissingBean 注解
  3. 业务系统自己也提供了同类型的 Bean
  4. starter 发现“已经有人提供了”,自己就退让
  5. 最终 DefaultLoginService 注入到的就是业务系统自己的实现

4.4.auth-login-spring-boot-starter 中核心实现

auth-login-spring-boot-starter 模块并不需要任何业务实现,但它却非常重要。

它的主要作用就是聚合组件中的所有依赖,通过 auth-login-spring-boot-starter 暴露入口让业务引用,依赖简洁,屏蔽内部模块拆分细节等。

springboot中的大量组件都是这个套路。

5.业务系统接入

首先引入自定义 starter 依赖

<dependency>
            <groupId>com.jag.auth</groupId>
            <artifactId>auth-login-spring-boot-starter</artifactId>
            <version>${project.version}</version>
        </dependency>

然后只需实现可扩展接口即可

在这里插入图片描述

6.完整源码

完整源码地址我放到评论区了

后续我们也可以将自定义的 starter 发布到 Maven 私服,这样就可以直接在公司内部使用了,关于如何发布到Maven私服涉及到的内容也不少,大家感兴趣的可以自己查查相关资料,后面有精力也会写一篇文章一起学习。

最近看到一个很扎心的现象:企业越来越关注开发效率,而 AI 正在成为新的生产力工具。同样的需求,会使用 AI 的工程师往往能够更快完成设计、编码和测试工作。与其担心被 AI 替代,不如尽早学会驾驭 AI。最近我不仅在学习 Java 底层,还在学习一些人工智能的知识,发现了一个不错的 AI 学习网站,内容通俗易懂,比较适合程序员快速上手,感兴趣的话也可以看看:人工智能学习网

One more thing

获得真正自由的方法是要学会自我控制。如果情绪总是处于失控状态就会被感情牵着鼻子走,丧失自由。所以那些精神自由,保持独立思考的人也正是擅长于控制自己情绪的人。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

最后一支迷迭香

您的赞赏将给作者加杯☕️

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值