Loading

SpringSecurity系列学习(四-番外):多因子验证和TOTP

系列导航

SpringSecurity系列

SpringSecurityOauth2系列

多因子验证

这一节属于话题外,讨论一下多因子认证

单纯的用户名/密码登陆在某些时候还是不够安全的,因为大部分用户会在各种平台采用同样的密码,只要有一个平台发生泄漏,那么很可能会影响其他平台,这要有一个平台发生泄漏,那么很可能影响其他平台,所以增加一步或者多步验证成了目前较流行的方案。

双因子登陆是最基础的,也是用户体验最好的。比如登陆的时候邮件或者短信验证,比如指纹,人脸认证等。

TOTP

基于时间的一次性密码

  • 一次性:
    • 在多步验证中,通常会在第二步采用随机密码,这个密码一般情况下是一个一次性密码,也就是验证之后就抛弃掉了,不允许进行多次使用同一个密码进行验证。

    • 但这存在一个问题,如果一直允许用户输入新的密码进行验证,这等于给了恶意用户不断尝试的机会。

  • 时间性: 为了解决上面的问题,提出了时间性 -> 这个一次性验证码是有时效期的。而且在有效期内,这个密码的生成应该一致的。过了这个时间之后,密码过期,不能进行认证。这就是基于时间的一次性密码。它是用一种以当前时间作为输入的算法生成的。

引入依赖

    <properties>
      ...
        <otp.version>0.2.0</otp.version>
      ...
    </properties>
        ...
        <!-- Java OTP 依赖 -->
        <dependency>
            <groupId>com.eatthepath</groupId>
            <artifactId>java-otp</artifactId>
            <version>${otp.version}</version>
        </dependency>
        ...

工具类

import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;

/**
 * Totp工具类
 * 用于一次性验证码
 *
 * @author 硝酸铜
 * @date 2021/9/22
 */
@Slf4j
@Component
public class TotpUtil {

    /**
     * 密码有效期,在有效期内,生成的所有密码都一样
     */
    private static final long TIME_STEP = 60 * 5L;

    /**
     * 密码长度
     */
    private static final int PASSWORD_LENGTH = 6;
    private KeyGenerator keyGenerator;
    private TimeBasedOneTimePasswordGenerator totp;

    /*
     * 初始化代码块,Java 8 开始支持。这种初始化代码块的执行在构造函数之前
     * 准确说应该是 Java 编译器会把代码块拷贝到构造函数的最开始。
     */
    {
        try {
            totp = new TimeBasedOneTimePasswordGenerator(Duration.ofSeconds(TIME_STEP), PASSWORD_LENGTH);
            keyGenerator = KeyGenerator.getInstance(totp.getAlgorithm());
            // SHA-1 and SHA-256 需要 64 字节 (512 位) 的 key; SHA512 需要 128 字节 (1024 位) 的 key
            keyGenerator.init(512);
        } catch (NoSuchAlgorithmException e) {
            log.error("没有找到算法 {}", e.getLocalizedMessage());
        }
    }

    /**
     * @param time 用于生成 TOTP 的时间
     * @return 一次性验证码
     * @throws InvalidKeyException 非法 Key 抛出异常
     */
    public String createTotp(final Key key, final Instant time) throws InvalidKeyException {
        String  format = "%0" + PASSWORD_LENGTH + "d";
        return String.format(format, totp.generateOneTimePassword(key, time));
    }

    public Optional<String> createTotp(final String strKey) {
        try {
            return Optional.of(createTotp(decodeKeyFromString(strKey), Instant.now()));
        } catch (InvalidKeyException e) {
            return Optional.empty();
        }
    }

    /**
     * 验证 TOTP
     *
     * @param code 要验证的 TOTP
     * @return 是否一致
     * @throws InvalidKeyException 非法 Key 抛出异常
     */
    public boolean validateTotp(final Key key, final String code) throws InvalidKeyException {
        Instant now = Instant.now();
        return createTotp(key, now).equals(code);
    }

    public Key generateKey() {
        return keyGenerator.generateKey();
    }

    public String encodeKeyToString(Key key) {
        return Base64.getEncoder().encodeToString(key.getEncoded());
    }

    public String encodeKeyToString() {
        return encodeKeyToString(generateKey());
    }

    public Key decodeKeyFromString(String strKey) {
        return new SecretKeySpec(Base64.getDecoder().decode(strKey), totp.getAlgorithm());
    }

    public long getTimeStepInLong() {
        return TIME_STEP;
    }

    public Duration getTimeStep() {
        return totp.getTimeStep();
    }
}

写个main方法调用一下

public static void main(String[] args) {
        TotpUtil util = new TotpUtil();
        String key = "vVgYlufzmEn0yJwDWYjEmyI6tY1UitlywKbOv8nM1nLioDzZEFCedK8+g1YwsDsA0n9vLC/4skzJE6EYQBSIXw==";
        try {
            String code = util.createTotp(util.decodeKeyFromString(key), Instant.now());
            System.out.println(code);
            System.out.println(util.validateTotp(util.decodeKeyFromString(key),code));
        } catch (InvalidKeyException e) {
            e.printStackTrace();
        }
    }
>>
606187
true

需要注意的是,这种验证码的key和JWT的key是不相同的。

JWT的key是整个系统共用的,而验证码的key应该是基于用户的不同而不同。

试想一下,如果整个系统共用一个验证码key,整个系统在同一段内时间发布的验证码都会一样,会带了很大的安全隐患。

多因子用户认证逻辑

  1. 用户登陆,根据User表中设定的属性字段,比如usingMfa,来决定是否启用多因子验证流程,或者判断上次用户登陆的IP与这一次登陆的IP差异较大,则需要进行多因子认证
  2. 在数据库中调出这个用户的key,生成TOTP
  3. 服务端这个时候其实应该返回一个认证失败的响应,因为如果用户需要进行多因子认证,那么用户名密码登陆成功,只是认证中一个字环节成功,整个认证过程还没有成功。为了区分和用户名密码认证失败的响应区分开,我们在相应头中加入X-Authenticate:mfa,reamlm=请求id,这个根据业务需求定义
  4. 客户端判断响应头是否有X-Authenticate:mfa,reamlm=请求id,根据用户的选择决定是短信发送还是电子邮件发送,进入不同的页面去进行短信或者电子邮箱验证
  5. 用户进行多因子验证,完成认证,服务端生成JTW给客户端

多因子认证的逻辑已经有了,

关于怎么发短信和邮件,这里不多做阐述

主要逻辑代码:

/**
 * Jwt认证过滤器
 * @author 硝酸铜
 * @date 2021/7/1
 */
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

...
    /**
     * 认证成功逻辑
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        //判断 是否需要多因子认证,如果是,则将用户信息放入缓存中,返回id
        User user = userService.getByUsername(authResult.getName());
        if(user.isUsingMfa()){

            //将用户信息存到缓存
            Integer cacheId = cacheService.cache(user);
            try {
                //登录成功時,返回json格式进行提示
                response.setContentType("application/json;charset=utf-8");
                response.setStatus(HttpServletResponse.SC_OK);
                //为了区分用户名和密码认证失败的响应区分开,添加响应头
                response.addHeader("X-Authenticate","mfa,reamlm=" + cacheId);

                PrintWriter out = response.getWriter();
                Map<String, Object> map = new HashMap<>();
                map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
                map.put("message", "账号密码认证成功,请进行下一步认证");
                out.write(new ObjectMapper().writeValueAsString(map));
                out.flush();
                out.close();
            } catch (Exception e1) {
                e1.printStackTrace();
            }
            return;
        }

        //如果不需要,则直接返回token
        //令牌私钥
        PrivateKey accessPrivateKey = null;
        PrivateKey refreshPrivateKey = null;
       ...
    }
  ...
}

怎么缓存看业务需求,微服务的话使用redis,如果是体量小的单体应用的话可以考虑使用caffeine,这里就不做阐述了。

接下来的逻辑就简单了,前端判断请求头,判断账号密码认证成功,然后调用多因子认证的接口,参数传入是短信还是邮箱。服务端接到请求后,根据传入的参数,从缓存中拿出用户信息,然后根据用户的totp key,去生成一个totp验证码,发送出去。

用户从邮件或者短信里面输入验证码,前端调用验证接口。服务器接到请求,通过id从缓存中拿用户信息,然后进行验证(验证方式是验证入参验证码和使用用户key生成的totp验证码是否一样,在设置时间内,同一个key生成的验证码是相同的),如果相同,则返回token,不相同则返回报错

posted @ 2021-09-27 16:41  硝酸铜  阅读(1750)  评论(0编辑  收藏  举报