四时宝库

程序员的知识宝库

别挠了,都快秃了,双因素认证我直接告诉你算了

两因素身份认证是如何工作的

从技术上讲,两(或多)因素身份认证是一个安全过程,用户必须提供两个或更多安全因素来让自己得到认证。也就是说,用户需要提供除密码以外的另一个标识符,例如:一次性密码、硬件令牌、生物特征(如:指纹)等。

该安全过程涉及到如下步骤:

  • 用户输入电子邮件(用户名)和密码。
  • 除了第一凭据,用户还要提交由认证应用生成的一次性代码。
  • 应用程序在对电子邮件(用户名)和密码进行身份认证的同时,也使用在注册过程中颁发的用户密钥来认证一次性代码

由此可见,与使用短信传递口令代码相比,使用诸如Google Authenticator、Microsoft Authenticator、以及FreeOTP等身份认证应用,既能够避免SIM卡遭受攻击又能够无需蜂窝网络或互联网连接,进行正常认证。

应用示例

下面,我们将逐步构建一个使用两因素身份认证技术的简单REST API。该API要求用户提供电子邮件密码对,和由应用生成的短代码。该应用会用到JDK 11、Maven、以及用于存储用户个人信息的MongoDB。其项目组织结构如下图所示:

在此,我不会遍历地介绍每一个组成部分,而只会专注于AuthService、TokenManager和TotpManager。这些部分主要负责身份的认证流程。它们分别提供了以下功能:

AuthService –该组件主要用于存储、认证和授权所有的业务逻辑,其中包括:注册、登录和令牌认证。

TokenManager–该组件通过抽象代码,以生成和认证JWT令牌。它能够使得主要业务逻辑的实现与具体的JWT库相互独立。

TotpManager–作为另一种抽象,它能够将实现与基本逻辑相隔离。TotpManager既可被用于生成用户的密钥,又可以断言(assert,可以立即为验证)给出的短代码。

由于在此仅关注认证组件,因此我们将从用户的创建过程(注册)开始,同时涉及到密钥的生成和令牌的颁发。接着,我们将进入登录流程,涉及一个由用户提供的短代码的断言。

实现注册流程

下面,我们将完成一个注册的过程,其中涉及以下步骤:

  • 从客户端获取注册请求。
  • 检查该用户是否存在。
  • 对密码进行哈希。
  • 生成一个密钥。
  • 将用户存储到数据库中。
  • 颁发JWT。
  • 返回带有用户ID、私钥和令牌的响应。

我将主要的业务逻辑(AuthServiceImpl)与令牌的生成,以及密钥的产生分离开来。

一般步骤

主要组件AuthServiceImpl会接受SignupRequest,并返回SignupResponse。在后台,它负责整个注册的逻辑。下面是具体的实现代码:

Java

1.  @Override 
2.  public Mono<SignupResponse> signup(SignupRequest request) { 
3.  // generating a new user entity params 
4.  // step 1 
5.  String email = request.getEmail().trim().toLowerCase(); 
6.  String password = request.getPassword(); 
7.  String salt = BCrypt.gensalt(); 
8.  String hash = BCrypt.hashpw(password, salt); 
9.  String secret = totpManager.generateSecret(); 
10. User user = new User(null, email, hash, salt, secret); 
11. // preparing a Mono 
12. Mono<SignupResponse> response = repository.findByEmail(email) 
13.         .defaultIfEmpty(user) // step 2 
14.         .flatMap(result -> { 
15.             // assert, that user does not exist 
16.             // step 3 
17.             if (result.getUserId() == null) { 
18.                 // step 4 
19.                 return repository.save(result).flatMap(result2 -> { 
20.                     // prepare token 
21.                     // step 5 
22.                     String userId = result2.getUserId(); 
23.                     String token = tokenManager.issueToken(userId); 
24.                     SignupResponse signupResponse = new SignupResponse(); 
25.                     signupResponse.setUserId(userId); 
26.                     signupResponse.setSecretKey(secret); 
27.                     signupResponse.setToken(token); 
28.                     signupResponse.setSuccess(true); 
29.                    
30.                     return Mono.just(signupResponse); 
31.                 }); 
32.             } else { 
33.                 // step 6 
34.                 // scenario - user already exists 
35.                 SignupResponse signupResponse = new SignupResponse(); 
36.                 signupResponse.setSuccess(false); 
37.                
38.                 return Mono.just(signupResponse); 
39.             } 
40.         }); 
41. return response; 

下面,让我们逐步解读上述实现的过程。在逻辑判读中:如果当前用户是新用户,我们将对其进行注册;如果该用户已经存在于数据库之中,那么我们就必须拒绝该请求。具体步骤为:

  • 我们根据请求数据创建一个新的用户实体,并生成一个相应的密钥。
  • 如果该用户过去不存在,则将给出的新实体作为其默认实体。
  • 检查存储库的调用结果。
  • 将用户保存在数据库中,并获取其userId。
  • 颁发JWT。
  • 如果用户已经存在,则返回一个拒绝响应。

相比以漏洞和安全问题而闻名的SHA函数,我在此选用jBcrypt库,来产生各种安全的哈希和salt(盐)。

生成密钥

接下来,我们需要实现一个用来生成新的密钥的函数。它是由TotpManager.generateSecret()内部抽象而来。下面是它的代码:

Java:

1.  @Override 
2.  public String generateSecret() { 
3.    SecretGenerator generator = new DefaultSecretGenerator(); 
4.    return generator.generate(); 
5.  } 

测试

实现了注册逻辑之后,我们需要测试它是否能够按预期进行认证。首先,让我们调用signup端点以创建一个新的用户。其结果对象应当包含我们需要添加到应用生成器(如:Google Authenticator)的userId、令牌和密钥:

不过,我们应当禁止同一封电子邮件两次进行注册。在此,我们通过断言,以保证应用在创建新用户之前,去检查现有的电子邮件列表:

登录

下面,我们来讨论登录流程。该流程包括两个主要部分:认证电子邮件的密码凭据,以及认证由用户提供的一次性代码。和上一节一样,我们首先介绍登录所涉及的步骤:

  • 从客户端获取登录请求。
  • 在数据库中找到该用户。
  • 使用请求中提供的密码进行断言。
  • 断言一次性代码。
  • 返回带有令牌的登录响应。

而JWT的生成过程与注册的过程比较类似。

一般步骤

作为该示例的功能重点,AuthServiceImpl.login将实现主要的业务逻辑。首先,我们需要通过在数据库中请求电子邮件,来查找用户;否则,我们需要提供带有空字段的默认值。也就是说,让user.getUserId() == null,以表示该用户并不存在,登录流程随即终止。

接着,我们需要断言密码的匹配。当我们将密码的哈希值存储在数据库中时,就需要使用存储的salt对请求中的密码进行哈希处理,进而断言这两个值。

如果密码匹配,我们需要使用之前存储的密钥值来认证提交的代码。认证成功与否的结果,将在产生JWT和创建LoginResponse对象后得出。以下便是此部分的最终源代码:

Java

1.  @Override 
2.  public Mono<LoginResponse> login(LoginRequest request) { 
3.    String email = request.getEmail().trim().toLowerCase(); 
4.    String password = request.getPassword(); 
5.    String code = request.getCode(); 
6.    Mono<LoginResponse> response = repository.findByEmail(email) 
7.    // step 1 
8.            .defaultIfEmpty(new User()) 
9.            .flatMap(user -> { 
10.               // step 2 
11.               if (user.getUserId() == null) { 
12.                   // no user 
13.                   LoginResponse loginResponse = new LoginResponse(); 
14.                   loginResponse.setSuccess(false); 
15.                  
16.                   return Mono.just(loginResponse); 
17.               } else { 
18.                   // step 3 
19.                   // user exists 
20.                   String salt = user.getSalt(); 
21.                   String secret = user.getSecretKey(); 
22.                   boolean passwordMatch = BCrypt.hashpw(password, salt).equalsIgnoreCase(user.getHash()); 
23.                  if (passwordMatch) { 
24.                      // step 4 
25.                      // password matched 
26.                      boolean codeMatched = totpManager.validateCode(code, secret); 
27.                      if (codeMatched) { 
28.                          // step 5 
29.                          String token = tokenManager.issueToken(user.getUserId()); 
30.                          LoginResponse loginResponse = new LoginResponse(); 
31.                          loginResponse.setSuccess(true); 
32.                          loginResponse.setToken(token); 
33.                          loginResponse.setUserId(user.getUserId()); 
34.                         
35.                          return Mono.just(loginResponse); 
36.                      } else { 
37.                          LoginResponse loginResponse = new LoginResponse(); 
38.                          loginResponse.setSuccess(false); 
39.                          return Mono.just(loginResponse); 
40.                      } 
41.                  } else { 
42.                      LoginResponse loginResponse = new LoginResponse(); 
43.                      loginResponse.setSuccess(false); 
44.                     
45.                      return Mono.just(loginResponse); 
46.                  } 
47.              } 
48.          }); 
49.  return response; 
50. } 

可见,后台的逻辑步骤为:

  • 提供具有空字段的默认用户实体。
  • 检查该用户是否确实存在。
  • 从请求和salt处生成密码的哈希,并存储在数据库中。
  • 断言密钥是否能够确实匹配。
  • 认证一次性代码,并颁发JWT。

断言一次性代码

为了认证由应用生成的一次性代码,我们必须向TOTP库提供相应的代码和密钥,并将它们保存为用户实体的一部分。具体代码如下:

Java

1.  @Override 
2.  public boolean validateCode(String code, String secret) { 
3.    TimeProvider timeProvider = new SystemTimeProvider(); 
4.    CodeGenerator codeGenerator = new DefaultCodeGenerator(); 
5.    CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); 
6.    return verifier.isValidCode(secret, code); 
7.  } 

测试

最后,我们可以通过测试,以认证登录的过程是否如期运行。我们将由Google Authenticator生成的代码作为登录请求的负载,去调用login端点。

如下图所示,为了检查出密码错误的情况,我们需要将进程终止在密码断言阶段:

至此,我们已经创建了一个简单的REST API,它可以通过Spring Webflux的TOTP来提供两因素身份认证。如前文所述,为了更专注于身份认证的逻辑,我们省略了所有的其他部分。

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言
    友情链接