mirror of
https://codeberg.org/readeck/readeck.git
synced 2025-12-22 05:07:08 +00:00
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:
@@ -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"),
|
||||
|
||||
34
assets/templates/auth/totp.jet.html
Normal file
34
assets/templates/auth/totp.jet.html
Normal 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 }}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
|
||||
5
internal/db/migrations/postgres/25_totp.sql
Normal file
5
internal/db/migrations/postgres/25_totp.sql
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
5
internal/db/migrations/sqlite3/25_totp.sql
Normal file
5
internal/db/migrations/sqlite3/25_totp.sql
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
213
pkg/totp/totp.go
Normal 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
182
pkg/totp/totp_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user