OTP 协议

如果大家经常用MFA认证登录的话,会看到如下场景,从App中拿到6位数字,然后输入,验证同构之后才能登录,这就是OTP协议。本篇文章介绍下什么是OTP协议以及它的实现。

otp

OTP协议及实现

什么是OTP协议

OTP(One-Time Password)协议是一种安全协议,它用于生成只能使用一次的密码。这种密码通常用于二次验证,即在用户输入常规密码之后,还需要输入由OTP系统生成的一次性密码才能访问服务或资源。这种方式增加了安全性,因为即使常规密码被泄露,没有对应的一次性密码,非法用户也无法入侵账户。

有几种方式可以生成和管理OTP:

  • 基于时间的一次性密码(TOTP):这种机制会按照给定时间间隔(通常是30秒)生成新的密码。服务器和客户端都基于同样的密钥和当前时间生成密码,所以它们各自生成的密码会一致,只要时间和密钥都同步。TOTP是开放标准,定义在RFC 6238中。

  • 基于事件或计数器的一次性密码(HOTP):这种方法是根据一个计数器生成密码,通常是当用户生成密码时才会增加计数。每次用户成功登录,计数器都会同步更新。HOTP也是开放标准,定义在RFC 4226中。

  • 短信或电子邮件OTP:服务器向用户注册的手机或电子邮箱发送OTP,用户收到后输入以完成验证。

Totp实现

这里着重介绍Topt的实现,Totp的实现大概分为几个步骤:

  1. 生成随机的secret,根据secret生成ProvisionUrl
  2. 根据生成的ProvisionUrl,生成二维码
  3. 用软件Micorosoft Authenticator扫描二维码,生成6位数

c#构造ProvisonURL和Verify Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
public class Totp
{
private Byte[] secret;
private static readonly DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private TimeSpan defaultClockDriftTolerance { get; set; } = TimeSpan.FromMinutes(1);

public Totp(String secret, Boolean isBase32 = true) => this.secret = ConvertSecretToBytes(secret, isBase32);

public static String GenerateProvisionUrl(
String account,
Byte[] secret,
String issuer)
{
if (String.IsNullOrWhiteSpace(account))
{
throw new ArgumentNullException(nameof(account));
}
var UrlEncode = (String url) => Uri.EscapeDataString(url);
var RemoveWhiteSpace = (String str) => new String(str.Where(c => !Char.IsWhiteSpace(c)).ToArray());

account = RemoveWhiteSpace(UrlEncode(account));
if (!String.IsNullOrEmpty(issuer))
{
issuer = RemoveWhiteSpace(UrlEncode(issuer));
}
var encodedSecret = Base32Encoding.ToString(secret).Trim('=');

return String.IsNullOrWhiteSpace(issuer)
? $"otpauth://totp/{account}?secret={encodedSecret}"
: $"otpauth://totp/{issuer}:{account}?secret={encodedSecret}&issuer={issuer}";

}

/// <summary>
/// Given a PIN from a client, check if it is valid at the current time.
/// </summary>
/// <param name="pin">The PIN from the client</param>
/// <returns>True if PIN is currently valid</returns>
public Boolean ValidatePIN(String pin) => ValidatePIN(pin, defaultClockDriftTolerance);

/// <summary>
/// Given a PIN from a client, check if it is valid at the current time.
/// </summary>
/// <param name="pin">The PIN from the client</param>
/// <param name="timeTolerance">
/// The time window within which to check to allow for clock drift between devices.
/// </param>
/// <returns>True if PIN is currently valid</returns>
public Boolean ValidatePIN(
String pin,
TimeSpan timeTolerance)
{
return GetCurrentPINs(timeTolerance).Any(c => c == pin);
}

/// <summary>
/// Get the PIN for current time; the same code that a 2FA app would generate for the current time.
/// Do not validate directly against this as clockdrift may cause a a different PIN to be generated
/// than one you did a second ago.
/// </summary>
/// <returns>A 6-digit PIN</returns>
public String GetCurrentPIN() =>
GeneratePINAtInterval(GetCurrentCounter());

/// <summary>
/// Get the PIN for current time; the same code that a 2FA app would generate for the current time.
/// Do not validate directly against this as clockdrift may cause a a different PIN to be generated
/// than one you did a second ago.
/// </summary>
/// <param name="now">The time you wish to generate the pin for</param>
/// <returns>A 6-digit PIN</returns>
public String GetCurrentPIN(DateTime now) =>
GeneratePINAtInterval(GetCurrentCounter(now, epoch, 30));


/// <summary>
/// Get all the PINs that would be valid within the time window allowed for by the specified clock drift.
/// </summary>
/// <param name="timeTolerance">The clock drift size you want to generate PINs for</param>
/// <returns></returns>
private String[] GetCurrentPINs(
TimeSpan timeTolerance)
{
var codes = new List<String>();
var iterationCounter = GetCurrentCounter();
var iterationOffset = 0;

if (timeTolerance.TotalSeconds > 30)
{
iterationOffset = Convert.ToInt32(timeTolerance.TotalSeconds / 30.00);
}

var iterationStart = iterationCounter - iterationOffset;
var iterationEnd = iterationCounter + iterationOffset;

for (var counter = iterationStart; counter <= iterationEnd; counter++)
{
codes.Add(GeneratePINAtInterval(counter));
}

return codes.ToArray();
}

private static Byte[] ConvertSecretToBytes(
String secret,
Boolean secretIsBase32) =>
secretIsBase32 ? Base32Encoding.ToBytes(secret) : Encoding.UTF8.GetBytes(secret);

/// <summary>
/// This method is generally called via <see cref="GoogleAuthenticator.GetCurrentPIN()" />/>
/// </summary>
/// <param name="counter">The number of 30-second (by default) intervals since the unix epoch</param>
/// <param name="digits">The desired length of the returned PIN</param>
/// <returns>A 'PIN' that is valid for the specified time interval</returns>
private String GeneratePINAtInterval(Int64 counter, Int32 digits = 6) =>
GenerateHashedCode(counter, digits);

/// <summary>
/// Calculate HMAC-Based One-Time-Passwords (HOTP) from a secret key
/// </summary>
/// <remarks>
/// The specifications for this are found in RFC 4226
/// http://tools.ietf.org/html/rfc4226
/// </remarks>
private String GenerateHashedCode(Int64 iterationNumber, Int32 digits = 6)
{
var counter = BitConverter.GetBytes(iterationNumber);

if (BitConverter.IsLittleEndian)
Array.Reverse(counter);

var hmac = new HMACSHA1(this.secret);
var hash = hmac.ComputeHash(counter);
var offset = hash[hash.Length - 1] & 0xf;

// The RFC has a hard coded index 19 in this value.
// This is the same thing but also accomodates SHA256 and SHA512
// hmac[19] => hmac[hmach.Length - 1]
var binary =
((hash[offset] & 0x7f) << 24)
| ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8)
| ((hash[offset + 3] & 0xff));

var password = binary % (Int32)Math.Pow(10, digits);
return password.ToString(new String('0', digits));
}

private Int64 GetCurrentCounter() => GetCurrentCounter(DateTime.UtcNow, epoch, 30);

private Int64 GetCurrentCounter(DateTime now, DateTime epoch, Int32 timeStep) =>
(Int64)(now - epoch).TotalSeconds / timeStep;
}

Generate二维码

1
2
3
4
5
6
7
8
9
10
11
import QRCode from 'qrcode'

const generateQRCode = () => {
Service.generateOtpUrlAsync().then(data => {
var canvas = document.getElementById('canvas')
QRCode.toCanvas(canvas, data.url, function (error) {
if (error) console.error(error)
console.log('success!');
})
})
}

最后实现的效果:

qrcode