package otp
import (
"errors"
"strings"
)
var (
ErrURIFormat = errors.New("uri format error")
ErrSecretDecode = errors.New("secret base32 decode error")
ErrSecretCannotBeEmpty = errors.New("secret cannot be empty")
)
var (
minSkewNumber = 0
minPeriodNumber = 10
)
// Algorithms 支持的 HMAC 类型。
//
// 默认值:HMAC_SHA1,与 Google Authenticator 兼容。
//
// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
type Algorithms int
const (
AlgorithmSHA1 Algorithms = iota + 1
AlgorithmSHA256
AlgorithmSHA512
)
// String 枚举值转换为字符串形式 - 该值可以放置在 uri 上。
func (h Algorithms) String() string {
switch h {
case AlgorithmSHA1:
return "SHA1"
case AlgorithmSHA256:
return "SHA256"
case AlgorithmSHA512:
return "SHA512"
default:
panic("unreachable")
}
}
// from 从字符串转换至 Algorithms 枚举
func (h Algorithms) from(str string) (Algorithms, error) {
switch strings.ToUpper(str) {
case "":
return AlgorithmSHA1, nil
case "SHA1":
return AlgorithmSHA1, nil
case "SHA256":
return AlgorithmSHA256, nil
case "SHA512":
return AlgorithmSHA512, nil
default:
return 0, errors.New("unknown 'algorithm' string")
}
}
// Digits 生成出来的一次性密码的长度。6 和 8 是最常见的值。
type Digits int
const (
DigitsSix Digits = 6
DigitsEight Digits = 8
)
// from 从 int 类型转换至 Digits 枚举
func (d Digits) from(i int) (Digits, error) {
switch i {
case 6:
return DigitsSix, nil
case 8:
return DigitsEight, nil
default:
return 0, errors.New("unknown 'digits' number")
}
}
package otp
import (
"crypto/hmac"
"fmt"
"net/url"
)
// HOTP 基于 RFC-4266 的 HOTP 算法
type HOTP struct {
Otp
// base32 encoded string
Secret string
// base32 decoded string
decodedSecret []byte
}
// NewHOTP 创建一个 HOTP 结构体,可以使用 option 的模式传递参数。
//
// Params:
//
// secret : 必传,一个 base32 编码后的字符串,建议使用 RandomSecret 方法生成的。
// WithCounter : 设置初始计数器,该值仅用于 KeyURI 方法。
// WithSkew : 是否校验相邻的窗口。
// WithAlgorithm: 设置 hmac 算法类型。
//
// Panic:
// - secret base32 decode error
// - secret is an empty string
//
// 注意: Google Authenticator 可能仅支持 Counter 这一个参数
//
// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
//
// Example:
//
// secret := Base32Encode(RandomSecret(20))
// hotp := NewHOTP(secret, WithCounter(2))
func NewHOTP(secret string, options ...Option) *HOTP {
if secret == "" {
panic(ErrSecretCannotBeEmpty)
}
decodedSecret, err := Base32Decode(secret)
if err != nil {
panic(ErrSecretDecode)
}
otp := Otp{
Skew: 0,
Counter: 1,
Period: 30,
Algorithm: AlgorithmSHA1,
Digits: DigitsSix,
}
for _, opt := range options {
opt(&otp)
}
return &HOTP{
Otp: otp,
Secret: secret,
decodedSecret: decodedSecret,
}
}
// At 通过指定的 Counter 生成一个 token。
//
// Example:
//
// hotp := NewHOTP(Base32Encode(RandomSecret(20)))
// token := hotp.At(1) // 使用的 1 作为counter 生成 token
// bool := hotp.Verify(token, 1) // 校验 token 是否有效
func (h *HOTP) At(counter int64) string {
s := intToByte(counter)
hashFunc := hasher(h.Algorithm)
mac := hmac.New(hashFunc, h.decodedSecret)
mac.Write(s)
hex := mac.Sum(nil)
return truncate(hex, int(h.Digits))
}
// Verify 校验token是否有效,窗口内的所有结果都认为有效。
//
// Params:
//
// token : 需要进行校验的参数,一个字符串,如果字符串为空将会返回 false
// counter: 计数器
//
// Example:
//
// hotp := NewHOTP(Base32Encode(RandomSecret(20)), WithSkew(1))
// token := hotp.At(2) // 使用的 2 作为counter 生成 token
// bool := hotp.Verify(token, 2) // 通过 WithSkew 方法指定 skew 参数为1,那么这里将会校验 counter 为 1、2、3 的token
func (h *HOTP) Verify(token string, counter int64) bool {
if token == "" {
return false
}
c := counter
for i := c - int64(h.Skew); i <= c+int64(h.Skew); i++ {
if h.At(i) == token {
return true
}
}
return false
}
// KeyURI 返回一个 KeyURI 结构体,其包含转换至 URI 和生成二维码的方法。
func (h *HOTP) KeyURI(account, issuer string) *KeyURI {
ret := &KeyURI{
Type: "hotp",
Label: url.PathEscape(fmt.Sprintf("%s:%s", issuer, account)),
Counter: h.Counter,
Digits: int(h.Digits),
Algorithm: h.Algorithm.String(),
Issuer: url.QueryEscape(issuer),
Secret: h.Secret,
}
return ret
}
package otp
import (
"fmt"
"github.com/skip2/go-qrcode"
"net/url"
"strconv"
"strings"
)
// KeyURI TOTP 或 HOTP 的 URI 包含的参数。
//
// URI 的格式可以参考:https://github.com/google/google-authenticator/wiki/Key-Uri-Format
//
// 部分属性 Google Authenticator 可能不会采用仅支持默认值。具体细节可以查看上面链接的文档。
type KeyURI struct {
// otp 算法的类型只能是 totp or hotp
Type string
// 标签,用于识别密钥与哪个帐户关联。它包含一个帐户名称,该名称是一个 URI 编码的字符串,可以选择以标识管理该帐户的提供商或服务的发行者字符串为前缀。
// 发行者前缀和帐户名称应使用文字或 URL 编码的冒号分隔,并且帐户名称之前可以有可选空格。发行人或账户名称本身都不能包含冒号。
// 根据 Google Authenticator 的建议,应该拼接发行商字符串为前缀。
// 需要已被 url.QueryEscape 方法处理过。
Label string
// hotp 或 totp 采用的哈希算法类型
// Google Authenticator 可能会忽略此参数,而采用默认值:HMAC-SHA1。
Algorithm string
// 向用户显示一次性密码的长度。默认值为 6。
// Google Authenticator 可能会忽略此参数,而采用默认值 6。
Digits int
// 当 type 为 hotp 时必选,它将设置初始计数器值。
Counter int64
// 仅当 type 为 totp 时可选,该 period 参数定义 TOTP 密码的有效期限(以秒为单位)。默认值为 30。
// Google Authenticator 可能会忽略此参数,而采用默认值 30。
Period int
// 发行商,使用 URL 编码进行编码的字符串
// 需要已被 url.QueryEscape 方法处理过。
Issuer string
// base32 编码的任意字符,不应该填充。
Secret string
}
// URI 生成 otpauth 的 URI 形式,可以将其作为二维码的内容供 Google Authenticator 扫码导入。
// params 顺序:secret、issuer、algorithm、digits、period、counter
func (p KeyURI) URI() *url.URL {
u := url.URL{}
u.Scheme = "otpauth"
u.Host = p.Type
u.Path = p.Label
params := "secret=" + p.Secret
params += "&issuer=" + p.Issuer
if p.Algorithm != "SHA1" {
params += "&algorithm=" + p.Algorithm
}
if p.Digits != 6 {
params += "&digits=" + strconv.Itoa(p.Digits)
}
if p.Type == "totp" {
if p.Period != 30 {
params += "&period=" + strconv.Itoa(p.Period)
}
} else {
params += "&counter=" + strconv.FormatInt(p.Counter, 10)
}
u.RawQuery = params
return &u
}
// QRCode 将此 URI 信息生成一个二维码,可供 Google Authenticator 扫码导入。
func (p KeyURI) QRCode() ([]byte, error) {
uri := p.URI().String()
code, err := qrcode.New(uri, qrcode.Highest)
if err != nil {
return nil, err
}
png, err := code.PNG(256)
if err != nil {
return nil, err
}
return png, nil
}
// FromURI 解析 URI 创建一个 KeyURI 结构体。
func FromURI(uri string) (*KeyURI, error) {
u, err := url.Parse(uri)
if err != nil {
return nil, ErrURIFormat
}
if u.Scheme != "otpauth" {
return nil, ErrURIFormat
}
if u.Host != "hotp" && u.Host != "totp" {
return nil, ErrURIFormat
}
query := u.Query()
issuer := query.Get("issuer")
secret := query.Get("secret")
if secret == "" {
return nil, ErrURIFormat
}
digits, err := atoi(query.Get("digits"), 6)
if err != nil {
return nil, ErrURIFormat
}
digitsEnum, err := Digits.from(DigitsSix, digits)
if err != nil {
return nil, ErrURIFormat
}
period, err := atoi(query.Get("period"), 30)
if err != nil || period < minPeriodNumber {
return nil, ErrURIFormat
}
counter, err := parseInt(query.Get("counter"), 1, 10, 64)
if err != nil {
return nil, ErrURIFormat
}
algorithm, err := Algorithms.from(AlgorithmSHA1, query.Get("algorithm"))
if err != nil {
return nil, ErrURIFormat
}
if u.Host == "hotp" {
period = 0
} else {
counter = 0
}
// 按照规则 issuer 和 account 都不能包含 ":"
path := strings.Split(u.Path, ":")
// 如果 label 存在 issuer 但是 params 中不存在 issuer
if issuer == "" {
if len(path) > 1 {
issuer = path[0][1:]
}
}
var label = u.Path[1:]
// 如果 label 不存在 issuer 但是 params 中存在 issuer
if len(path) == 1 && issuer != "" {
label = fmt.Sprintf("%s:%s", issuer, u.Path[1:])
}
key := &KeyURI{
Type: u.Host,
Label: label,
Algorithm: algorithm.String(),
Digits: int(digitsEnum),
Counter: counter,
Period: period,
Issuer: issuer,
Secret: secret,
}
return key, nil
}
package otp
type Otp struct {
// 指定时间窗口,默认 30 秒有效期。
// Google Authenticator 可能仅支持默认参数。
Period int
// 初始计数器数值,默认为 1。
// 该参数仅用来指定 otpauth uri 上的 counter 参数,不会使用它来生成 token
Counter int64
// 指定一次性密码的长度,默认 6 位数字。
// Google Authenticator 可能仅支持默认参数。
Digits Digits
// 是否校验相邻的时间窗口,默认为 0。
// 有些时候服务端的时间和客户端的时间并不是同步的,存在时间误差,再加上网络延时,一次性密码的剩余有效期等等,密码刚到达服务端可能就过期了,
// 这时候可以通过此参数为相邻几个时间窗口进行校验,加强用户体验,但是安全性降低了。
// 如果此参数为1,那么会同时校验当前时间窗口、上个时间窗口以及下个时间窗口。如果是 HOTP 那么就是相邻的计数器。
Skew int
// 指定 hmac 算法,默认 hmac-sha1
// Google Authenticator 可能仅支持默认参数。
Algorithm Algorithms
}
type Option func(opt *Otp)
// WithSkew 配置同时校验的窗口数,默认为 0 仅校验当前时间窗口。
//
// 取值范围是:skew >=0 如果传入的值小于 0 将会设置为 0。
func WithSkew(skew int) Option {
return func(opt *Otp) {
if skew < minSkewNumber {
skew = minSkewNumber
}
opt.Skew = skew
}
}
// WithDigits 配置一次性密码的显示长度,默认为 6, Google Authenticator 可能不支持其他的长度。
func WithDigits(digits Digits) Option {
return func(opt *Otp) {
opt.Digits = digits
}
}
// WithPeriod 配置时间一次性密码的有效期,默认 30 秒,仅支持 TOTP 类型。
//
// 取值范围是:period >=10 如果传入的值小于 10 将会设置为 10。
func WithPeriod(period int) Option {
return func(opt *Otp) {
if period < minPeriodNumber {
period = minPeriodNumber
}
opt.Period = period
}
}
// WithCounter 配置计数器的值,默认为 1 (Google 的默认就是 1),仅支持 HOTP 类型。
func WithCounter(counter int64) Option {
return func(opt *Otp) {
opt.Counter = counter
}
}
// WithAlgorithm 配置哈希算法类型。
func WithAlgorithm(algorithm Algorithms) Option {
return func(opt *Otp) {
opt.Algorithm = algorithm
}
}
package otp
import (
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base32"
"hash"
"math"
"strconv"
"strings"
)
// RandomSecret 获取一个给定长度(字节数)的随机秘钥,如果生成失败将会 panic。
//
// 建议存储时将其转换至 base32 或其他的编码,直接转换成字符串可能会存在换行符等奇怪的字符。
//
// 内部使用 rand.Read 方法,如果此方法报错将会 panic
//
// rfc4266 中建议 secret 最少为 160 位也就是 20 个字节。
//
// https://datatracker.ietf.org/doc/html/rfc4226
//
// 也可看下此文档解释自行选择合适长度:
//
// https://github.com/darrenedale/php-totp/blob/HEAD/Secrets.md
func RandomSecret(length int) []byte {
// 建议选择适合对应 hmac 算法的长度。
// HMAC-SHA1 建议选择 20 字节长度
// HMAC-SHA256 建议选择 32 字节长度
// HMAC-SHA512 建议选择 64 字节长度
randomBytes := make([]byte, length)
_, err := rand.Read(randomBytes)
if err != nil {
panic(err)
}
return randomBytes
}
// Base32Decode 对一个字符串进行 base32 解码
func Base32Decode(str string) ([]byte, error) {
// base32 只包含大小字母
upper := strings.ToUpper(str)
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(upper)
}
// Base32Encode 对一个字符串进行 base32 编码
func Base32Encode(str []byte) string {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(str)
}
// padZero 在字符串的签名填充数字0
func padZero(value string, size int) string {
if len(value) >= size {
return value
}
return strings.Repeat("0", size-len(value)) + value
}
// intToByte 数字转换成二进制字节格式
func intToByte(number int64) []byte {
result := make([]byte, 0, 8)
shifts := []uint{56, 48, 40, 32, 24, 16, 8, 0}
for _, shift := range shifts {
result = append(result, byte((number>>shift)&0xff))
}
return result
}
// truncate 计算出指定位数的数字字符串(不足位数前面补0)
func truncate(h []byte, digits int) string {
offset := h[len(h)-1] & 0xf
bits := uint32(h[offset]&0x7f)<<24 |
uint32(h[offset+1]&0xff)<<16 |
uint32(h[offset+2]&0xff)<<8 |
uint32(h[offset+3]&0xff)
value := bits % uint32(math.Pow10(digits))
return padZero(strconv.Itoa(int(value)), digits)
}
func hasher(algorithm Algorithms) func() hash.Hash {
switch algorithm {
case AlgorithmSHA1:
return sha1.New
case AlgorithmSHA256:
return sha256.New
case AlgorithmSHA512:
return sha512.New
default:
panic("unreachable")
}
}
func atoi(str string, def int) (int, error) {
if str == "" {
return def, nil
}
val, err := strconv.Atoi(str)
if err != nil {
return 0, err
}
return val, nil
}
func parseInt(str string, def int64, base int, bitSize int) (int64, error) {
if str == "" {
return def, nil
}
val, err := strconv.ParseInt(str, base, bitSize)
if err != nil {
return 0, err
}
return val, nil
}
package otp
import (
"crypto/hmac"
"fmt"
"net/url"
"time"
)
// TOTP 基于 RFC-6238 的 TOTP 算法
type TOTP struct {
Otp
// base32 encoded string
Secret string
// base32 decoded string
decodedSecret []byte
}
// NewTOTP 创建一个 TOTP 结构体,可以使用 option 的模式传递参数。
//
// Params:
//
// secret : 必传,一个 base32 编码后的字符串,建议使用 RandomSecret 方法生成的。
// WithPeriod : 设置 token 有效期长度。
// WithSkew : 是否校验相邻的窗口。
// WithAlgorithm: 设置 hmac 算法类型。
//
// Panic:
// - secret base32 decode error
// - secret is an empty string
//
// 默认参数才是 Google Authenticator 兼容的,自定义参数的话 Google Authenticator 可能不会识别。
//
// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format
//
// Example:
//
// secret := Base32Encode(RandomSecret(20))
// totp := NewTOTP(secret, WithDigits(DigitsEight))
func NewTOTP(secret string, options ...Option) *TOTP {
if secret == "" {
panic(ErrSecretCannotBeEmpty)
}
decodedSecret, err := Base32Decode(secret)
if err != nil {
fmt.Println(err, secret)
panic(ErrSecretDecode)
}
otp := Otp{
Skew: 0,
Counter: 1,
Period: 30,
Algorithm: AlgorithmSHA1,
Digits: DigitsSix,
}
for _, opt := range options {
opt(&otp)
}
return &TOTP{
Otp: otp,
Secret: secret,
decodedSecret: decodedSecret,
}
}
// Now 基于当前时间点生成 token。
func (o *TOTP) Now() string {
return o.At(time.Now())
}
// At 生成某个时间点的 token。
func (o *TOTP) At(t time.Time) string {
key := intToByte(t.Unix() / int64(o.Period))
hashFunc := hasher(o.Algorithm)
mac := hmac.New(hashFunc, o.decodedSecret)
mac.Write(key)
h := mac.Sum(nil)
return truncate(h, int(o.Digits))
}
// WithExpiration 获取指定时间的 token 和对应的剩余有效时间。
func (o *TOTP) WithExpiration(t time.Time) (string, int) {
token := o.At(t)
expiration := o.Expiration(t)
return token, expiration
}
// Expiration 获取指定时间窗口的 token 剩余有效时间。
func (o *TOTP) Expiration(t time.Time) int {
return int(int64(o.Period) - t.Unix()%int64(o.Period))
}
// Verify 校验 token 是否在指定的时间有效。
//
// Params:
//
// token: 需要进行校验的参数,一个字符串,如果字符串为空将会返回 false。
// t : 指定的时间,用以校验 token 在这个时间点是否仍有效。
func (o *TOTP) Verify(token string, t time.Time) bool {
if token == "" {
return false
}
givenTime := t
sec := t.Unix()
for i := o.Skew * -1; i <= o.Skew; i++ {
givenTime = time.Unix(sec, 0).Add(time.Second * time.Duration(o.Period*i))
if o.At(givenTime) == token {
return true
}
}
return false
}
// KeyURI 返回一个 KeyURI 结构体,其包含转换至 URI 和生成二维码的方法。
func (o *TOTP) KeyURI(account, issuer string) *KeyURI {
ret := &KeyURI{
Type: "totp",
Label: url.PathEscape(fmt.Sprintf("%s:%s", issuer, account)),
Algorithm: o.Algorithm.String(),
Digits: int(o.Digits),
Period: o.Period,
Issuer: url.QueryEscape(issuer),
Secret: o.Secret,
}
return ret
}