4

背景

在当前这个项目中,老师说建个 issue 来实现一个万能一次性密码,简称 OTP;第一次听的时候,感觉是很厉害的东西,密码还能一次性、居然还是万能的。然后参照团队之前老师和学长们写的代码来尝试实现

OTP 是什么

其实我们日常生活中的 “验证码” 就是一种 OTP。

一句话总结:一次性密码(OTP) 是一种只能使用一次的密码。它在首次使用后立即失效,因此即使被黑客窃取,也无法被再次利用,从而极大地提升了安全性。

为什么需要 OTP

传统的静态密码(自己设置的固定密码)存在很多风险:

  • 容易被窃取
  • 容易被重复使用
  • 容易被猜测或者暴力破解

然而,OTP 有着“动态变化”和“一次性使用”这两个核心特性,就可以完美的解决上述的问题

主要的工作原理

OTP的生成通常基于一个“种子”和一个动态变化的因素。这其中的动态因素主要有三种,也对应了三种主流的 OTP 类型:

  • 基于时间(TOTP - 时间同步一次性密码)

    • 原理:客户端(你的手机App)和服务器共享一个“密钥”。双方根据同一个时间戳(通常是30秒一个间隔)和同一个算法,分别独立生成一个密码
    • 流程:你登录时,服务器会检查你输入的OTP是否与它当前计算出的密码,或者前一个时间窗口(考虑到时间轻微不同步)的密码匹配
    • 优点:不需要网络连接(短信需要),离线也能生成
    • 例子:银行安全令牌、认证器App(Google Authenticator, Authy等)

    image.png
    image.png

  • 基于事件(HOTP - 基于HMAC的一次性密码)
  • 基于短信/邮件(SMS OTP)

    实现过程

    认证流程

    graph TD
      A[用户提交登录请求] --> B[UsernamePasswordAuthenticationFilter]
      B --> C[生成 UsernamePasswordAuthenticationToken]
      C --> D[AuthenticationManager 调用 ProviderManager]
      D --> E[DaoAuthenticationProvider]
      E --> F[调用 YzBCryptPasswordEncoder.matches]
      
      F --> G{检查是否为一次性密码}
      G -->|是| H[验证一次性密码]
      H --> I[标记一次性密码为已使用]
      I --> J[返回 true]
      
      G -->|否| K[调用父类 BCrypt.matches]
      K --> L[正常BCrypt密码验证]
      L --> M[返回验证结果]
      
      J --> N[认证成功]
      M --> N

    结合 spring security 的时序图

image.png

组件的关系图

classDiagram
    class YzBCryptPasswordEncoder {
        -OneTimePasswordService oneTimePasswordService
        +matches(CharSequence, String) boolean
    }
    
    class BCryptPasswordEncoder {
        +matches(CharSequence, String) boolean
    }
    
    class OneTimePasswordService {
        +getPassword() Optional~String~
    }
    
    class DaoAuthenticationProvider {
        -PasswordEncoder passwordEncoder
        +authenticate(Authentication) Authentication
    }
    
    class WebConfig {
        -YzBCryptPasswordEncoder passwordEncoder
        +authenticationManager() AuthenticationManager
        +filterChain() SecurityFilterChain
    }
    
    YzBCryptPasswordEncoder --|> BCryptPasswordEncoder : 继承
    YzBCryptPasswordEncoder --> OneTimePasswordService : 依赖
    DaoAuthenticationProvider --> YzBCryptPasswordEncoder : 使用
    WebConfig --> YzBCryptPasswordEncoder : 创建并注入
            

代码实现

  1. 在 YzBCryptPasswordEncoder 校验密码的时候,加入对"是否启用超密码"的验证
  2. 实现获取 OTP 的密码的 service 方法
  3. 在 webConfig 中注入 YzBCryptPasswordEncoder
  4. 验证:结合第三方插件来实现登录验证

在 YzBCryptPasswordEncoder 中的匹配方法中加入对超密码的验证

@Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (rawPassword == null) {
            throw new IllegalArgumentException("rawPassword cannot be null");
        }

        logger.debug("当有一次性密码(每个密码仅能用一次)且未使用时,验证用户是否输入了超密");
        Optional<String> oneTimePassword = this.oneTimePasswordService.getPassword();

        if (oneTimePassword.isPresent() && oneTimePassword.get().equals(rawPassword.toString())) {
            logger.warn("当前正在使用超密码登陆");
            return true;
        }
         return super.matches(rawPassword, encodedPassword);
    }

实现 OneTimePasswordService 中的 getPassword

可以看出来,我们这里实现的是基于时间的 OTP
@Service
public class OneTimePasswordServiceImpl implements OneTimePasswordService {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    private String password = "";

    private final String token;

    // todo: 下一个 issue 实现通过获取系统配置中的 ONE_TIME_PASSWORD_TOKEN 来设置 token 的值
    private final String otpToken = "abc";

    public OneTimePasswordServiceImpl() {
        Base32 base32 = new Base32();
        this.token = base32.encodeAsString(otpToken.getBytes());
    }

    /**
     * 仅允许获取1次,获取成功之后的 code 值为 null
     * @return
     */
    @Override
    public Optional<String> getPassword() {
        try {
            String password = TimeBasedOneTimePasswordUtil.generateCurrentNumberString(this.token);
            // 每个密码只能用一次,如果生成的密码与当前的密码相同,则说明短时间内请求了两次,返回 empty
            if (password.equals(this.password)) {
                return Optional.empty();
            } else {
                this.password = password;
            }
        } catch (GeneralSecurityException e) {
            this.logger.error("生成一次性密码时发生错误");
            e.printStackTrace();
        }

        return Optional.of(this.password);
    }
}

结合 Google Authenticator 来登录我们当前的系统来验证我们的 OTP 是否设置成功
其中的对应关系:

  • Issuer(标识) = otpToken ("abc")
  • Secret(密钥) = token (abc Base32编码后的值)

image.png

✅ 测试成功!

image.png

遗留的问题

背景

想要实现的效果是,使用系统的用户可以根据自己的需要修改 otpToken 的值;即放在系统配置中,但是启动的时候却发生了错误

问题

image.png

定位错误代码:

image.png

image.png

当时的想法:是不是初始化系统配置中的 ONE_TIME_PASSWORD_TOKEN 的时候没有初始化成功,然后构造函数去找的时候才没有找到呢

image.png

结果却并不是这样的,这里的初始化断点甚至没有运行就直接报错了!
💡 spring 的执行顺序本身就是先执行 构造函数 再进行 CommandLineRunner 的初始化
所以问题的关键是OneTimePasswordServiceImpl 构造方法运行的时间,早于 CommandLineRunner 的执行时间


💣 Spring 的生命周期顺序是:

  1. 构造 service Bean(此时还没有执行 CommandLineRunner)
  2. 初始化 AOP、依赖注入等
  3. ApplicationContext 启动完成
  4. !!!!此时才执行CommandLineRunner

也就是说:V012InitOneTimePasswordTokenConfig.run() 只有在所有 Bean 都创建完毕后才会执行

image.png

当前找的方法是:使用 flyaway 来初始化数据,而不是 startup

因为Flyway 的执行发生在数据源初始化后、Bean 构造之前

总结

第一次了解了 OTP 是什么,才发现了在生活中用得很多!然后,这次的代码编写几乎是参考团队历史上已有的代码,所以实现起来很快。在写的过程中有认真去理解核心的实现逻辑。对之前的 spring security 的了解也是更加深了。但是还是不足,以及要开始适当的根据编写的过程总结一下 spring 的生命周期了


vuxuan
71 声望9 粉丝