Go进阶40:2FA-Google-Authenticator双因素认证后端实现

Go进阶40:2FA-Google-Authenticator双因素认证后端实现

1. 前言

随着越来越多的公司对安全的重视,普通的帐号密码验证码方式不能满足日益严峻的安全需求.很多网站APP都要求用户开启双因子认证,而Google Authenticator是支持最广泛的双因子认证APP,这篇文章我们将介绍使用Go语言开发符合RFC6238和Google Authenticator规范的双因子认证后端服务,同时给读者提供开发2FA程序的思路.

双重认证(英语:Two-factor authentication,缩写为2FA), 又译为双重验证,双因子认证,双因素认证,二元认证,又称两步骤验证(2-Step Verification,又译两步验证), 是一种认证方法,使用两种不同的元素,合并在一起,来确认用户的身份,是多因素验证中的一个特例.

  • 使用银行卡时,需要另外输入PIN码,确认之后才能使用其转账功能.
  • 登陆电脑版微信时,用已经登录同一账号的手机版微信扫描特定二维码进行验证.
  • 登陆校园网系统时,通过手机短信或学校指定的手机软件进行验证.
  • 登陆Steam和Uplay等游戏平台时,使用手机令牌或Google身份验证器进行验证.

2. TOTP的概念

TOTP 的全称是”基于时间的一次性密码”(Time-based One-time Password). 它是公认的可靠解决方案,已经写入国际标准 RFC6238.

它的步骤如下.

  • 第一步,用户开启双因素认证后,服务器生成一个密钥.
  • 第二步:服务器提示用户扫描二维码(或者使用其他方式),把密钥保存到用户的手机.也就是说,服务器和用户的手机,现在都有了同一把密钥.
  • 第三步,用户登录时,手机客户端使用这个密钥和当前时间戳,生成一个哈希,有效期默认为30秒.用户在有效期内,把这个哈希提交给服务器.(注意,密钥必须跟手机绑定.一旦用户更换手机,就必须生成全新的密钥.)
  • 第四步,服务器也使用密钥和当前时间戳,生成一个哈希,跟用户提交的哈希比对.只要两者不一致,就拒绝登录.

3. RFC6238

根据RFC 6238标准,供参考的实现如下:

  • 生成一个任意字节的字符串密钥K,与客户端安全地共享.
  • 基于T0的协商后,Unix时间从时间间隔(TI)开始计数时间步骤,TI则用于计算计数器C(默认情况下TI的数值是T0和30秒)的数值
  • 协商加密哈希算法(默认为SHA-1)
  • 协商密码长度(默认6位)

4. Google Authenticator 2FA双因素认证 Golang 代码实现 TOTP

Google Authenticator 生成一次性密码的伪代码

function GoogleAuthenticatorCode(string secret)
  key := base32decode(secret)
  message := floor(current Unix time / 30)
  hash := HMAC-SHA1(key, message)
  offset := last nibble of hash
  truncatedHash := hash[offset..offset+3]  //4 bytes starting at the offset
  Set the first bit of truncatedHash to zero  //remove the most significant bit
  code := truncatedHash mod 1000000
  pad code with 0 until length of code is 6
  return code

生成事件性或计数性的一次性密码伪代码

function GoogleAuthenticatorCode(string secret)
  key := base32decode(secret)
  message := counter encoded on 8 bytes
  hash := HMAC-SHA1(key, message)
  offset := last nibble of hash
  truncatedHash := hash[offset..offset+3]  //4 bytes starting at the offset
  Set the first bit of truncatedHash to zero  //remove the most significant bit
  code := truncatedHash mod 1000000
  pad code with 0 until length of code is 6
  return code
  • 关于代码中为什么会出现难懂的位运算 -> 追求运算效率,

下面的代码展示使用golang生成和验证符合google authenticator 规范的2FA. 后端代码完全兼容市面上类似Google Authenticator App 和小程序. https://github.com/mojocn/blogcode/blob/main/google_authenticator_test.go

package blogcode

import (
	"crypto/hmac"
	"crypto/sha1"
	"encoding/base32"
	"encoding/binary"
	"errors"
	"fmt"
	"net/url"
	"testing"
	"time"
)

//GoogleAuthenticator2FaSha1 只实现google authenticator sha1
type GoogleAuthenticator2FaSha1 struct {
	Base32NoPaddingEncodedSecret string //The base32NoPaddingEncodedSecret parameter is an arbitrary key value encoded in Base32 according to RFC 3548. The padding specified in RFC 3548 section 2.2 is not required and should be omitted.
	ExpireSecond                 uint64 //更新周期单位秒
	Digits                       int    //数字数量
}

//otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
const testSecret = "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ" //base32-no-padding-encoded-string

//Totp 计算Time-based One-time Password 数字
func (m *GoogleAuthenticator2FaSha1) Totp() (code string, err error) {
	count := uint64(time.Now().Unix()) / m.ExpireSecond
	key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(m.Base32NoPaddingEncodedSecret)
	if err != nil {
		return "", errors.New("https://github.com/google/google-authenticator/wiki/Key-Uri-Format,REQUIRED: The base32NoPaddingEncodedSecret parameter is an arbitrary key value encoded in Base32 according to RFC 3548. The padding specified in RFC 3548 section 2.2 is not required and should be omitted.")
	}
	codeInt := hotp(key, count, m.Digits)
	intFormat := fmt.Sprintf("%%0%dd", m.Digits) //数字长度补零
	return fmt.Sprintf(intFormat, codeInt), nil
}

//QrString google authenticator 扫描二维码的二维码字符串
func (m *GoogleAuthenticator2FaSha1) QrString(label, issuer string) (qr string) {
	issuer = url.QueryEscape(label) //有一些小程序MFA不支持
	//规范文档 https://github.com/google/google-authenticator/wiki/Key-Uri-Format
	//otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30
	return fmt.Sprintf(`otpauth://totp/%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d`, label, m.Base32NoPaddingEncodedSecret, issuer, m.Digits, m.ExpireSecond)
}

func hotp(key []byte, counter uint64, digits int) int {
	//RFC 6238
	//只支持sha1
	h := hmac.New(sha1.New, key)
	binary.Write(h, binary.BigEndian, counter)
	sum := h.Sum(nil)
	//取sha1的最后4byte
	//0x7FFFFFFF 是long int的最大值
	//math.MaxUint32 == 2^32-1
	//& 0x7FFFFFFF == 2^31  Set the first bit of truncatedHash to zero  //remove the most significant bit
	// len(sum)-1]&0x0F 最后 像登陆 (bytes.len-4)
	//取sha1 bytes的最后4byte 转换成 uint32
	v := binary.BigEndian.Uint32(sum[sum[len(sum)-1]&0x0F:]) & 0x7FFFFFFF
	d := uint32(1)
	//取十进制的余数
	for i := 0; i < digits && i < 8; i++ {
		d *= 10
	}
	return int(v % d)
}
func TestTotp(t *testing.T) {
	g := GoogleAuthenticator2FaSha1{
		Base32NoPaddingEncodedSecret: testSecret,
		ExpireSecond:                 30,
		Digits:                       6,
	}
	totp, err := g.Totp()
	if err != nil {
		t.Error(err)
		return
	}
	t.Log(totp)
}

func TestQr(t *testing.T) {
	g := GoogleAuthenticator2FaSha1{
		Base32NoPaddingEncodedSecret: testSecret,
		ExpireSecond:                 30,
		Digits:                       6,
	}
	qrString := g.QrString("TechBlog:mojotv.cn", "Eric Zhou")
	t.Log(qrString)
}

5 FAQ

5.1 为什么2FA有时候验证不正确?

服务器时间和手机的时间有相差, 手机必须开启时间自动同步. 建议服务器使用 crontab 定时运行 ntp同步服务器时间. 这里以Centos为例:

5.1.1 centos 修正本地时区及ntp服务

yum -y install ntp   
rm -rf /etc/localtime
ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime  #设置时区
/usr/sbin/ntpdate -u pool.ntp.org
#ntpdate命令同步外网http://pool.ntp.org这个时间服务器.
# -u 指示ntpdate 将无特权的端口用于外发的数据包.在防火墙后,如果阻塞向特权端口的传入流量,并且您希望与防火墙后的主机进行同步,则该选项极为有用.请注意, -d 选项始终使用无特权的端口.

5.1.2 centos 自动同步时间

#添加下面一段
#表示每10分钟同步一次
crontab -e
#crontab -e 是Linux下面的一个命令,编辑自动同步,打开后加入上面一段内容后,就保存.
*/10 * * * *  /usr/sbin/ntpdate -u pool.ntp.org >/dev/null 2>&1
service crond restart
#重启服务之用.

5.2 Google Authenticator 无法使用?

服务器生成的2FA-secret必须是有效的base32-no-padding编码格式

5.3 怎么生成Google Authenticator的二维码?

后端调用func (m *GoogleAuthenticator2FaSha1) QrString(label, issuer string) (qr string) 生成google-authenticator-url,让前端使用二维码NPM包,把url转换成二维码图片展示. 我经常使用的前端Vuejs二维码库是 npm install --save qrcode.vue # yarn add qrcode.vue. 当然你也可以让前端参照这个网站来实现二维码 https://rootprojects.org/authenticator/

5.4 给手机和邮件发送 2FA?

在不知道2FA之前,我向手机或者Email发送2FA都是使用一个缓存过期数据来见来校验,验证码是否正确. 这种方法虽然可行但是代码量大,依赖数据库比较麻烦.使用一下方法就可以大大简化验证码业务逻辑.

https://github.com/mojocn/blogcode/blob/main/google_authenticator_test.go

func TestMakePhoneOrEmail2FACode(t *testing.T) {
	yourAppSecret := "server_config_secret"
	phoneOrEmailOrUserId := "13312345678" //neochau@gmail.com
	//base 32 no padding encode 是必须的不能省去
	googleAuthenticatorKey := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(yourAppSecret + phoneOrEmailOrUserId))
	mfa := GoogleAuthenticator2FaSha1{
		Base32NoPaddingEncodedSecret: googleAuthenticatorKey,
		ExpireSecond:                 300,//五分钟
		Digits:                       4,//数字
	}
	code, err := mfa.Totp()
	if err != nil {
		t.Error(err)
		return
	}
	t.Log("使用这个code发送给用户邮箱手机,","也可以使用这个code来,验证码收到的code if== 服务器计算的code",code)
}

5.5 不要使用任何非Google Authenticator

微信小程序中的MFA app 不兼容google authenticator secret规范. 导致2FA 数字出现错误.

Secret REQUIRED: The secret parameter is an arbitrary key value encoded in Base32 according to RFC 3548. The padding specified in RFC 3548 section 2.2 is not required and should be omitted.

Android手机APK下载: https://os-android.liqucn.com/rj/225046.shtml iOS手机直接:AppStore 搜索 google authenticator

6. 参考

目录