TOTP authentication

This is only the first part. When a totp_secret exists for a user, the
authentication then asks for the code and carries on.

The totp lib can handle 6 or 8 letter codes and sha1, sha256 and sha512.
For maximum compatibility with Google Authenticator though, it sticks
to 6 character and sha1.
This commit is contained in:
Olivier Meunier
2025-11-23 14:52:32 +01:00
parent 3f5f50ed5d
commit 822d78d57d
15 changed files with 607 additions and 45 deletions

View File

@@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
inputAttrs=attrList(
"data-login-form-target", "username",
"autocapitalize", "off",
"autocomplete", "username",
),
) }}
{{ yield passwordField(field=.Form.Get("password"),

View File

@@ -0,0 +1,34 @@
{*
SPDX-FileCopyrightText: © 2021 Olivier Meunier <olivier@neokraft.net>
SPDX-License-Identifier: AGPL-3.0-only
*}
{{ extends "./base" }}
{{ import "/_libs/forms" }}
{{ block title() }}{{ gettext("Two-factor authentication") }}{{ end }}
{{ block main() }}
<h2 class="text-h3 mb-8 text-center">{{ yield title() }}</h2>
<form action="{{ urlFor(`/login/mfa`) }}" method="post">
{{ yield formErrors(form=.Form) }}
<input type="hidden" name="redirect" value="{{ .Form.Get(`redirect`).String() }}" />
{{ yield textField(field=.Form.Get("code"),
label=gettext("Passcode"),
class="max",
inputClass="form-input w-full text-center text-lg font-bold",
inputAttrs=attrList(
"minlength", "6",
"maxlength", "6",
"autocapitalize", "off",
"autocomplete", "one-time-code",
),
) }}
<button class="btn btn-default block mt-6 w-full rounded-md" type="submit">{{ gettext("Verify") }}</button>
</form>
{{ end }}

View File

@@ -16,6 +16,7 @@ var Keys = KeyMaterial{}
const (
keyToken = "api_token"
keySession = "session"
keyTOTP = "totp_code"
keyOauthRequest = "oauth_request"
)
@@ -24,6 +25,7 @@ type KeyMaterial struct {
prk []byte // Main pseudorandom key
tokenKey SigningKey
sessionKey []byte
totpKey EncodingKey
oauthRequestKey EncodingKey
}
@@ -49,6 +51,11 @@ func (km KeyMaterial) SessionKey() []byte {
return km.sessionKey
}
// TOTPKey returns a 256-bit key used to encrypt the TOTP secret information.
func (km KeyMaterial) TOTPKey() EncodingKey {
return km.totpKey
}
// OauthRequestKey returns a 256-bit key used to encode the oauth
// authorization payload.
func (km KeyMaterial) OauthRequestKey() EncodingKey {
@@ -77,6 +84,7 @@ func loadKeys() {
// Derived keys
Keys.tokenKey = Keys.mustExpand(keyToken, 32)
Keys.sessionKey = Keys.mustExpand(keySession, 32)
Keys.totpKey = Keys.mustExpand(keyTOTP, 32)
Keys.oauthRequestKey = Keys.mustExpand(keyOauthRequest, 32)
}

View File

@@ -42,6 +42,7 @@ func checkUser(f forms.Binder) *users.User {
f.AddErrors("", errInvalidLogin)
return nil
}
if !user.CheckPassword(f.Get("password").String()) {
f.AddErrors("", errInvalidLogin)
return nil

View File

@@ -7,12 +7,18 @@ package signin
import (
"net/http"
"net/url"
"strings"
"time"
"github.com/doug-martin/goqu/v9"
"github.com/go-chi/chi/v5"
"codeberg.org/readeck/readeck/configs"
"codeberg.org/readeck/readeck/internal/auth/users"
"codeberg.org/readeck/readeck/internal/server"
"codeberg.org/readeck/readeck/pkg/forms"
"codeberg.org/readeck/readeck/pkg/totp"
)
// SetupRoutes mounts the routes for the auth domain.
@@ -37,6 +43,8 @@ func newAuthHandler(s *server.Server) *authHandler {
s.AddRoute("/login", r)
r.Get("/", h.login)
r.Post("/", h.login)
r.Get("/mfa", h.mfa)
r.Post("/mfa", h.mfa)
// Recovery
r.With(server.WithPermission("email", "send")).Route("/recover", func(r chi.Router) {
@@ -54,6 +62,21 @@ func newAuthHandler(s *server.Server) *authHandler {
return h
}
func (h *authHandler) redirTo(w http.ResponseWriter, r *http.Request, redir string) {
// Get redirection from a form "redirect" parameter
// Since it goes to Redirect(), it will be sanitized there
// and can only stay within the app.
if redir == "" || strings.HasPrefix(redir, "/login") {
redir = "/"
}
server.Redirect(w, r, redir)
}
func (h *authHandler) redirToMFA(w http.ResponseWriter, r *http.Request, redir string) {
v := url.Values{"r": {redir}}
server.Redirect(w, r, "/login/mfa?"+v.Encode())
}
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
f := newLoginForm(server.Locale(r))
@@ -62,8 +85,12 @@ func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
f.Get("redirect").Set(r.URL.Query().Get("r"))
// Do we have a session already?
if server.GetSession(r).Payload.User != 0 {
server.Redirect(w, r, h.cleanRedir(f.Get("redirect").String()))
if sess := server.GetSession(r); sess.Payload.User != 0 {
if sess.Payload.RequiresMFA {
h.redirToMFA(w, r, f.Get("redirect").String())
return
}
h.redirTo(w, r, f.Get("redirect").String())
return
}
}
@@ -73,17 +100,21 @@ func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
if f.IsValid() {
user := checkUser(f)
if user != nil {
// User is authenticated, let's carry on
sess := server.GetSession(r)
sess.Payload.User = user.ID
sess.Payload.Seed = user.Seed
sess.Payload.RequiresMFA = user.RequiresMFA()
sess.Save(w, r)
// Get redirection from a form "redirect" parameter
// Since it goes to Redirect(), it will be sanitized there
// and can only stay within the app.
server.Redirect(w, r, h.cleanRedir(f.Get("redirect").String()))
if sess.Payload.RequiresMFA {
h.redirToMFA(w, r, f.Get("redirect").String())
return
}
h.redirTo(w, r, f.Get("redirect").String())
return
}
// we must set the content type to avoid the
@@ -99,6 +130,76 @@ func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
})
}
func (h *authHandler) mfa(w http.ResponseWriter, r *http.Request) {
sess := server.GetSession(r)
if !sess.Payload.RequiresMFA {
server.Redirect(w, r, "/")
return
}
user := new(users.User)
found, err := users.Users.Query().
SelectAppend(goqu.C("totp_secret").Table("u")).
Where(goqu.C("id").Eq(sess.Payload.User)).
ScanStruct(user)
if err != nil {
server.Err(w, r, err)
return
}
if !found {
server.Status(w, r, 404)
return
}
f := forms.Must(
forms.WithTranslator(r.Context(), server.Locale(r)),
forms.NewTextField("code", forms.Required, forms.StrLen(6, 6)),
forms.NewTextField("redirect"),
)
if r.Method == http.MethodGet {
// Set the redirect value from the query string
f.Get("redirect").Set(r.URL.Query().Get("r"))
}
status := http.StatusOK
if r.Method == http.MethodPost {
forms.Bind(f, r)
if f.IsValid() {
code := new(totp.Code)
if err := configs.Keys.TOTPKey().DecodeJSON(user.TOTPSecret, code); err != nil {
server.Err(w, r, err)
return
}
ok, err := code.Verify(f.Get("code").String(), time.Now().UTC(), 1)
if err != nil {
server.Err(w, r, err)
return
}
if ok {
sess.Payload.RequiresMFA = false
sess.Save(w, r)
redir := f.Get("redirect").String()
if redir == "" || strings.HasPrefix(redir, "/login") {
redir = "/"
}
server.Redirect(w, r, redir)
return
}
f.Get("code").AddErrors(forms.Gettext("Invalid code"))
}
status = http.StatusUnprocessableEntity
}
server.RenderTemplate(w, r, status, "/auth/totp", server.TC{
"Form": f,
})
}
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
// Clear session
sess := server.GetSession(r)
@@ -106,10 +207,3 @@ func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
server.Redirect(w, r, "/login")
}
func (h *authHandler) cleanRedir(redir string) string {
if redir == "" || strings.HasPrefix(redir, "/login") {
redir = "/"
}
return redir
}

View File

@@ -47,17 +47,18 @@ var (
// User is a user record in database.
type User struct {
locked bool
ID int `db:"id" goqu:"skipinsert,skipupdate"`
UID string `db:"uid"`
Created time.Time `db:"created" goqu:"skipupdate"`
Updated time.Time `db:"updated"`
Username string `db:"username"`
Email string `db:"email"`
Password string `db:"password"`
Group string `db:"group"`
Settings *UserSettings `db:"settings"`
Seed int `db:"seed"`
locked bool
ID int `db:"id" goqu:"skipinsert,skipupdate"`
UID string `db:"uid"`
Created time.Time `db:"created" goqu:"skipupdate"`
Updated time.Time `db:"updated"`
Username string `db:"username"`
Email string `db:"email"`
Password string `db:"password"`
Group string `db:"group"`
Settings *UserSettings `db:"settings"`
Seed int `db:"seed"`
TOTPSecret []byte `db:"totp_secret"`
}
// Manager is a query helper for user entries.
@@ -197,6 +198,16 @@ func (u *User) SetSeed() int {
return u.Seed
}
// HasTOTP returns true if the user has a totp secret.
func (u *User) HasTOTP() bool {
return len(u.TOTPSecret) > 0
}
// RequiresMFA returns true if an MFA method is required upon sign-in.
func (u *User) RequiresMFA() bool {
return u.HasTOTP()
}
// IsAnonymous returns true when the instance is not set to any existing user
// (when ID is 0).
func (u *User) IsAnonymous() bool {

View File

@@ -103,4 +103,5 @@ var migrationList = []migrationEntry{
newMigrationEntry(21, "token_roles", migrations.M21tokenRoles),
newMigrationEntry(23, "oauth2", migrations.M23oauth),
newMigrationEntry(24, "user_uid_fix", migrations.M24useruidFix),
newMigrationEntry(25, "user_totp_secret", applyMigrationFile("25_totp.sql")),
}

View File

@@ -0,0 +1,5 @@
-- SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
--
-- SPDX-License-Identifier: AGPL-3.0-only
ALTER TABLE user ADD COLUMN totp_secret bytea NULL;

View File

@@ -9,16 +9,17 @@ CREATE TABLE migration (
);
CREATE TABLE IF NOT EXISTS "user" (
id SERIAL PRIMARY KEY,
uid varchar(32) UNIQUE NOT NULL,
created timestamptz NOT NULL,
updated timestamptz NOT NULL,
username varchar(128) UNIQUE NOT NULL,
email varchar(128) UNIQUE NOT NULL,
password varchar(256) NOT NULL,
"group" varchar(64) NOT NULL DEFAULT 'user',
settings jsonb NOT NULL DEFAULT '{}',
seed integer NOT NULL DEFAULT 0
id SERIAL PRIMARY KEY,
uid varchar(32) UNIQUE NOT NULL,
created timestamptz NOT NULL,
updated timestamptz NOT NULL,
username varchar(128) UNIQUE NOT NULL,
email varchar(128) UNIQUE NOT NULL,
password varchar(256) NOT NULL,
"group" varchar(64) NOT NULL DEFAULT 'user',
settings jsonb NOT NULL DEFAULT '{}',
seed integer NOT NULL DEFAULT 0,
totp_secret bytea NULL
);
CREATE TABLE IF NOT EXISTS token (

View File

@@ -0,0 +1,5 @@
-- SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
--
-- SPDX-License-Identifier: AGPL-3.0-only
ALTER TABLE user ADD COLUMN totp_secret blob NULL;

View File

@@ -9,16 +9,17 @@ CREATE TABLE migration (
);
CREATE TABLE IF NOT EXISTS user (
id integer PRIMARY KEY AUTOINCREMENT,
uid text UNIQUE NOT NULL,
created datetime NOT NULL,
updated datetime NOT NULL,
username text UNIQUE NOT NULL,
email text UNIQUE NOT NULL,
password text NOT NULL,
`group` text NOT NULL DEFAULT "user",
settings json NOT NULL DEFAULT "{}",
seed integer NOT NULL DEFAULT 0
id integer PRIMARY KEY AUTOINCREMENT,
uid text UNIQUE NOT NULL,
created datetime NOT NULL,
updated datetime NOT NULL,
username text UNIQUE NOT NULL,
email text UNIQUE NOT NULL,
password text NOT NULL,
`group` text NOT NULL DEFAULT "user",
settings json NOT NULL DEFAULT "{}",
seed integer NOT NULL DEFAULT 0,
totp_secret blob NULL
);
CREATE TABLE IF NOT EXISTS token (

View File

@@ -224,6 +224,10 @@ func (p *SessionAuthProvider) checkSession(sess *sessions.Session) (u *users.Use
return
}
if sess.Payload.RequiresMFA {
return
}
if u, err = users.Users.GetOne(goqu.C("id").Eq(sess.Payload.User)); err != nil {
return nil, err
}
@@ -325,7 +329,7 @@ func (p *ForwardedAuthProvider) Handler(next http.Handler) http.Handler {
sess.Payload.User = user.ID
sess.Payload.Seed = user.Seed
sess.Payload.External = true
// TODO: sess.Payload.NeedsMFA = user.IsTOTPEnabled()
sess.Payload.RequiresMFA = user.RequiresMFA()
encoded, err := SessionHandler().Encode(sess.Payload)
if err != nil {

View File

@@ -22,6 +22,7 @@ type Payload struct {
External bool `json:"x"`
Seed int `json:"s"`
User int `json:"u"`
RequiresMFA bool `json:"mfa"`
LastUpdate time.Time `json:"lu"`
Flashes []FlashMessage `json:"f"`
Preferences Preferences `json:"p"`

213
pkg/totp/totp.go Normal file
View File

@@ -0,0 +1,213 @@
// SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
// Package totp provides the building block the generate
// and check a TOTP key.
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1" //nolint:gosec
"crypto/sha256"
"crypto/sha512"
"crypto/subtle"
"encoding/base32"
"encoding/binary"
"errors"
"fmt"
"hash"
"maps"
"math"
"net/url"
"slices"
"strconv"
"strings"
"time"
)
var (
errInvalidBase32 = errors.New("invalid secret")
errInvalidCodeLength = errors.New("code length unexpected")
)
// Algorithm is a TOTP algorithm.
type Algorithm uint8
const (
// SHA1 is the default, google authenticator compatible, algorithm.
SHA1 Algorithm = iota
// SHA256 is the sha256 hmac algorithm.
SHA256
// SHA512 is the sha256 hmac algorithm.
SHA512
)
func (a Algorithm) String() string {
switch a {
case SHA1:
return "SHA1"
case SHA256:
return "SHA256"
case SHA512:
return "SHA512"
}
panic("invalid algorithm")
}
// New returns a [hash.Hash] based on the algorithm value.
func (a Algorithm) New() hash.Hash {
switch a {
case SHA1:
return sha1.New() //nolint:gosec
case SHA256:
return sha256.New()
case SHA512:
return sha512.New()
}
panic("invalid algorithm")
}
// Code contains all the TOTP code information.
// It can verify an OTP code and generate an URL for QR codes.
type Code struct {
Algorithm Algorithm `json:"a"`
Digits uint8 `json:"d"`
Period uint8 `json:"p"`
Secret string `json:"s"`
Issuer string `json:"i"`
Account string `json:"u"`
}
// NewCode returns a [Code] that's compatible with Google Authenticator.
// (SHA1, 6 digit, 30s).
func NewCode(secret string) Code {
return Code{
Algorithm: SHA1,
Digits: 6,
Period: 30,
Secret: secret,
}
}
// Generate returns a new [Code], Google Authenticator compatible,
// with a 32 character random secret.
func Generate() Code {
return NewCode(GenerateSecret())
}
// URL returns a [url.URL] of the code as defined in
// https://github.com/google/google-authenticator/wiki/Key-Uri-Format
func (c Code) URL() *url.URL {
v := url.Values{
"secret": {c.Secret},
"algorithm": {c.Algorithm.String()},
"digits": {strconv.FormatUint(uint64(c.Digits), 10)},
"period": {strconv.FormatUint(uint64(c.Period), 10)},
}
if c.Issuer != "" {
v.Set("issuer", c.Issuer)
}
u := &url.URL{
Scheme: "otpauth",
Host: "totp",
Path: c.Issuer,
RawQuery: encodeQuery(v),
}
if c.Account != "" {
u.Path += ":" + c.Account
}
return u
}
// OTP returns a user code valid for the given time.
func (c Code) OTP(t time.Time) (string, error) {
secret := strings.TrimSpace(c.Secret)
secret = strings.ToUpper(secret)
if n := len(secret) % 8; n != 0 {
secret += strings.Repeat("=", 8-n)
}
secretBytes, err := base32.StdEncoding.DecodeString(secret)
if err != nil {
return "", fmt.Errorf("%w: %w", errInvalidBase32, err)
}
counter := uint64(math.Floor(float64(t.Unix()) / float64(30)))
buf := make([]byte, 8)
mac := hmac.New(c.Algorithm.New, secretBytes)
binary.BigEndian.PutUint64(buf, counter)
mac.Write(buf)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0xf
value := int64(((int(sum[offset]) & 0x7f) << 24) |
((int(sum[offset+1] & 0xff)) << 16) |
((int(sum[offset+2] & 0xff)) << 8) |
(int(sum[offset+3]) & 0xff))
mod := int32(value % int64(math.Pow10(int(c.Digits))))
format := fmt.Sprintf("%%0%dd", c.Digits)
return fmt.Sprintf(format, mod), nil
}
// Verify returns true when a given user code is valid in the given time.
// The skew parameter can stretch (left and right) the period of validity.
func (c Code) Verify(otp string, t time.Time, skew uint8) (bool, error) {
otp = strings.TrimSpace(otp)
if len(otp) != int(c.Digits) {
return false, errInvalidCodeLength
}
times := []time.Time{t}
for range skew {
times = append(times, t.Add(-time.Duration(c.Period)*time.Second))
times = append(times, t.Add(time.Duration(c.Period)*time.Second))
}
for _, x := range times {
code, err := c.OTP(x)
if err != nil {
return false, err
}
if subtle.ConstantTimeCompare([]byte(code), []byte(otp)) == 1 {
return true, nil
}
}
return false, nil
}
// GenerateSecret returns a 20-bytes, hex encoded, random value (32 characters).
func GenerateSecret() string {
b := make([]byte, 20)
rand.Read(b)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}
func encodeQuery(v url.Values) string {
if v == nil {
return ""
}
var buf strings.Builder
for _, k := range slices.Sorted(maps.Keys(v)) {
vs := v[k]
keyEspaced := url.PathEscape(k)
for _, v := range vs {
if buf.Len() > 0 {
buf.WriteByte('&')
}
buf.WriteString(keyEspaced)
buf.WriteByte('=')
buf.WriteString(url.PathEscape(v))
}
}
return buf.String()
}

182
pkg/totp/totp_test.go Normal file
View File

@@ -0,0 +1,182 @@
// SPDX-FileCopyrightText: © 2025 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
package totp
import (
"encoding/base32"
"fmt"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRFCMatrix(t *testing.T) {
secSha1 := base32.StdEncoding.EncodeToString([]byte("12345678901234567890"))
secSha256 := base32.StdEncoding.EncodeToString([]byte("12345678901234567890123456789012"))
secSha512 := base32.StdEncoding.EncodeToString([]byte("1234567890123456789012345678901234567890123456789012345678901234"))
tests := []struct {
ts int64
otp string
code Code
}{
{59, "94287082", Code{Algorithm: SHA1, Digits: 8, Secret: secSha1}},
{59, "46119246", Code{Algorithm: SHA256, Digits: 8, Secret: secSha256}},
{59, "90693936", Code{Algorithm: SHA512, Digits: 8, Secret: secSha512}},
{1111111109, "07081804", Code{Algorithm: SHA1, Digits: 8, Secret: secSha1}},
{1111111109, "68084774", Code{Algorithm: SHA256, Digits: 8, Secret: secSha256}},
{1111111109, "25091201", Code{Algorithm: SHA512, Digits: 8, Secret: secSha512}},
{1111111111, "14050471", Code{Algorithm: SHA1, Digits: 8, Secret: secSha1}},
{1111111111, "67062674", Code{Algorithm: SHA256, Digits: 8, Secret: secSha256}},
{1111111111, "99943326", Code{Algorithm: SHA512, Digits: 8, Secret: secSha512}},
{1234567890, "89005924", Code{Algorithm: SHA1, Digits: 8, Secret: secSha1}},
{1234567890, "91819424", Code{Algorithm: SHA256, Digits: 8, Secret: secSha256}},
{1234567890, "93441116", Code{Algorithm: SHA512, Digits: 8, Secret: secSha512}},
{2000000000, "69279037", Code{Algorithm: SHA1, Digits: 8, Secret: secSha1}},
{2000000000, "90698825", Code{Algorithm: SHA256, Digits: 8, Secret: secSha256}},
{2000000000, "38618901", Code{Algorithm: SHA512, Digits: 8, Secret: secSha512}},
{20000000000, "65353130", Code{Algorithm: SHA1, Digits: 8, Secret: secSha1}},
{20000000000, "77737706", Code{Algorithm: SHA256, Digits: 8, Secret: secSha256}},
{20000000000, "47863826", Code{Algorithm: SHA512, Digits: 8, Secret: secSha512}},
}
for _, test := range tests {
label := fmt.Sprintf("%s %s", test.code.Algorithm, test.otp)
t.Run(label, func(t *testing.T) {
assert := require.New(t)
otp, err := test.code.OTP(time.Unix(test.ts, 0).UTC())
assert.NoError(err)
assert.Equal(test.otp, otp)
ok, err := test.code.Verify(otp, time.Unix(test.ts, 0).UTC(), 0)
assert.NoError(err)
assert.True(ok)
})
}
}
func TestGeneric(t *testing.T) {
sec1 := "ZYTYYE5FOAGW5ML7LRWUL4WTZLNJAMZS"
sec2 := "PW4YAYYZVDE5RK2AOLKUATNZIKAFQLZO"
tests := []struct {
ts int64
otp string
code Code
}{
// Tests from https://github.com/creachadair/otp
{1642868750, "349451", NewCode("aaaabbbbccccdddd")},
{1642868800, "349712", NewCode("aaaabbbbccccdddd")},
{1642868822, "367384", NewCode("aaaabbbbccccdddd")},
{1642869021, "436225", NewCode("aaaabbbbccccdddd")},
// Tests from https://github.com/susam/mintotp
{0, "549419", NewCode(sec1)},
{0, "009551", NewCode(sec2)},
{10, "549419", NewCode(sec1)},
{10, "009551", NewCode(sec2)},
{1260, "626854", NewCode(sec1)},
{1260, "093610", NewCode(sec2)},
{1270, "626854", NewCode(sec1)},
{1270, "093610", NewCode(sec2)},
}
for i, test := range tests {
t.Run(strconv.Itoa(i+1), func(t *testing.T) {
assert := require.New(t)
otp, err := test.code.OTP(time.Unix(test.ts, 0).UTC())
assert.NoError(err)
assert.Equal(test.otp, otp)
ok, err := test.code.Verify(test.otp, time.Unix(test.ts, 0).UTC(), 0)
assert.NoError(err)
assert.True(ok)
})
}
}
func TestSkew(t *testing.T) {
tests := []struct {
t time.Time
otp string
}{
{time.Unix(60, 0).UTC(), "336321"},
{time.Unix(30, 0).UTC(), "663947"},
{time.Unix(90, 0).UTC(), "128204"},
}
assert := require.New(t)
code := NewCode("ETBZKJG2XXN4XY4IRV3AETV6IGICCO35")
otp, err := code.OTP(tests[0].t)
assert.NoError(err)
assert.Equal(tests[0].otp, otp)
for _, test := range tests {
t.Run(test.otp, func(t *testing.T) {
ok, err := code.Verify(test.otp, tests[0].t, 1)
require.NoError(t, err)
require.True(t, ok)
})
}
}
func TestGenerateSecret(t *testing.T) {
assert := require.New(t)
for range 100 {
c := Generate()
assert.NotEmpty(c.Secret)
assert.Len(c.Secret, 32)
_, err := base32.StdEncoding.DecodeString(c.Secret)
assert.NoError(err)
}
}
func TestCodeURL(t *testing.T) {
tests := []struct {
code Code
expected string
}{
{
Code{
Secret: "ETBZKJG2XXN4XY4IRV3AETV6IGICCO35",
Algorithm: SHA1,
Digits: 6,
Period: 30,
Issuer: "issuer",
Account: "user",
},
"otpauth://totp/issuer:user?algorithm=SHA1&digits=6&issuer=issuer&period=30&secret=ETBZKJG2XXN4XY4IRV3AETV6IGICCO35",
},
{
Code{
Secret: "ETBZKJG2XXN4XY4IRV3AETV6IGICCO35",
Algorithm: SHA1,
Digits: 8,
Period: 30,
},
"otpauth://totp?algorithm=SHA1&digits=8&period=30&secret=ETBZKJG2XXN4XY4IRV3AETV6IGICCO35",
},
{
Code{
Secret: "ETBZKJG2XXN4XY4IRV3AETV6IGICCO35",
Algorithm: SHA512,
Digits: 8,
Period: 60,
Issuer: "ACME Co",
Account: "user@example.org",
},
"otpauth://totp/ACME%20Co:user@example.org?algorithm=SHA512&digits=8&issuer=ACME%20Co&period=60&secret=ETBZKJG2XXN4XY4IRV3AETV6IGICCO35",
},
}
for i, test := range tests {
t.Run(strconv.Itoa(i+1), func(t *testing.T) {
require.Equal(t, test.expected, test.code.URL().String())
})
}
}