- TOTP (RFC 6238, stdlib-only) enrollment in profile, login step-up, admin emergency reset. - Admins can grant a user visibility into individual clients (User.ClientIDs) in addition to whole-server access (User.ServerIDs). - New "My Access" page: non-admin users see only their assigned clients (view/QR/download only, no management), reachable from the main nav. - GetUser/GetUsers now redact TOTPSecret before returning JSON. No Go toolchain was available while writing this - not yet build-verified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PvrfUytqd74H6WcQkRzFM4
149 lines
4.0 KiB
Go
149 lines
4.0 KiB
Go
// Package auth implements a minimal, dependency-free TOTP (RFC 6238) /
|
|
// HOTP (RFC 4226) implementation used for per-user two-factor login.
|
|
//
|
|
// Only the Go standard library is used so that adding 2FA support does not
|
|
// require any go.mod/go.sum changes.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"crypto/subtle"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// totpPeriod is the standard TOTP time step, in seconds.
|
|
totpPeriod = 30
|
|
// totpDigits is the number of digits in the generated code.
|
|
totpDigits = 6
|
|
// secretSize is the number of random bytes used to build a secret,
|
|
// matching the common 160-bit (20 byte) recommendation for HMAC-SHA1.
|
|
secretSize = 20
|
|
)
|
|
|
|
// base32Encoding encodes/decodes secrets without padding, using the
|
|
// standard (uppercase) RFC 4648 base32 alphabet, matching the otpauth
|
|
// convention used by authenticator apps.
|
|
var base32Encoding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
|
|
|
// GenerateSecret creates a new random, base32-encoded TOTP secret.
|
|
func GenerateSecret() (string, error) {
|
|
buf := make([]byte, secretSize)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", fmt.Errorf("cannot generate random secret: %w", err)
|
|
}
|
|
return strings.ToUpper(base32Encoding.EncodeToString(buf)), nil
|
|
}
|
|
|
|
// generateCodeAtCounter computes the HOTP code for a given counter value,
|
|
// per RFC 4226.
|
|
func generateCodeAtCounter(secretBase32 string, counter uint64) (string, error) {
|
|
secret, err := decodeSecret(secretBase32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
msg := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(msg, counter)
|
|
|
|
mac := hmac.New(sha1.New, secret)
|
|
mac.Write(msg)
|
|
sum := mac.Sum(nil)
|
|
|
|
offset := sum[len(sum)-1] & 0x0f
|
|
truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
|
|
|
|
mod := uint32(1)
|
|
for i := 0; i < totpDigits; i++ {
|
|
mod *= 10
|
|
}
|
|
code := truncated % mod
|
|
|
|
return fmt.Sprintf("%0*d", totpDigits, code), nil
|
|
}
|
|
|
|
// decodeSecret normalizes and base32-decodes a secret string.
|
|
func decodeSecret(secretBase32 string) ([]byte, error) {
|
|
clean := strings.ToUpper(strings.TrimSpace(secretBase32))
|
|
clean = strings.ReplaceAll(clean, " ", "")
|
|
secret, err := base32Encoding.DecodeString(clean)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid totp secret: %w", err)
|
|
}
|
|
return secret, nil
|
|
}
|
|
|
|
// counterAt returns the TOTP counter value for the given time.
|
|
func counterAt(t time.Time) uint64 {
|
|
return uint64(t.Unix() / totpPeriod)
|
|
}
|
|
|
|
// GenerateCode returns the 6-digit TOTP code for secretBase32 valid at time t.
|
|
func GenerateCode(secretBase32 string, t time.Time) (string, error) {
|
|
return generateCodeAtCounter(secretBase32, counterAt(t))
|
|
}
|
|
|
|
// Validate checks whether code is a valid TOTP code for secretBase32 at
|
|
// time t, allowing +/- one 30-second step of clock skew tolerance. The
|
|
// final comparison is constant-time.
|
|
func Validate(secretBase32, code string, t time.Time) bool {
|
|
code = strings.TrimSpace(code)
|
|
if len(code) != totpDigits {
|
|
return false
|
|
}
|
|
|
|
counter := counterAt(t)
|
|
// Check current step first, then the adjacent steps (skew tolerance).
|
|
for _, delta := range []int64{0, -1, 1} {
|
|
c := counter
|
|
if delta < 0 {
|
|
if c == 0 {
|
|
continue
|
|
}
|
|
c--
|
|
} else if delta > 0 {
|
|
c++
|
|
}
|
|
|
|
expected, err := generateCodeAtCounter(secretBase32, c)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
if subtle.ConstantTimeCompare([]byte(expected), []byte(code)) == 1 {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ProvisioningURI builds an otpauth:// URI suitable for encoding into a QR
|
|
// code and scanning with any standard authenticator app.
|
|
func ProvisioningURI(secretBase32, accountName, issuer string) string {
|
|
label := fmt.Sprintf("%s:%s", issuer, accountName)
|
|
|
|
u := url.URL{
|
|
Scheme: "otpauth",
|
|
Host: "totp",
|
|
Path: "/" + label,
|
|
}
|
|
|
|
q := url.Values{}
|
|
q.Set("secret", secretBase32)
|
|
q.Set("issuer", issuer)
|
|
q.Set("algorithm", "SHA1")
|
|
q.Set("digits", fmt.Sprintf("%d", totpDigits))
|
|
q.Set("period", fmt.Sprintf("%d", totpPeriod))
|
|
u.RawQuery = q.Encode()
|
|
|
|
return u.String()
|
|
}
|