mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
106 lines
3.3 KiB
Go
106 lines
3.3 KiB
Go
// Package mfa provides TOTP-based two-factor authentication (RFC 6238).
|
|
// Compatible with Google Authenticator, Authy, and any standard TOTP app.
|
|
// No external dependencies — uses only the Go standard library.
|
|
package mfa
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha1"
|
|
"encoding/base32"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
totpDigits = 6
|
|
totpPeriod = 30 // seconds
|
|
totpWindow = 2 // accept ±2 periods (±60s) to handle clock skew and slow input
|
|
)
|
|
|
|
// GenerateSecret creates a new random 20-byte (160-bit) TOTP secret,
|
|
// returned as a base32-encoded string (the standard format for authenticator apps).
|
|
func GenerateSecret() (string, error) {
|
|
secret := make([]byte, 20)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
return "", fmt.Errorf("generate secret: %w", err)
|
|
}
|
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil
|
|
}
|
|
|
|
// OTPAuthURL builds an otpauth:// URI for QR code generation.
|
|
// issuer is the application name (e.g. "GoWebMail"), accountName is the user's email.
|
|
func OTPAuthURL(issuer, accountName, secret string) string {
|
|
v := url.Values{}
|
|
v.Set("secret", secret)
|
|
v.Set("issuer", issuer)
|
|
v.Set("algorithm", "SHA1")
|
|
v.Set("digits", fmt.Sprintf("%d", totpDigits))
|
|
v.Set("period", fmt.Sprintf("%d", totpPeriod))
|
|
|
|
label := url.PathEscape(issuer + ":" + accountName)
|
|
return fmt.Sprintf("otpauth://totp/%s?%s", label, v.Encode())
|
|
}
|
|
|
|
// QRCodeURL returns a Google Charts URL that renders the otpauth URI as a QR code.
|
|
// In production you'd generate this server-side; this is convenient for self-hosted use.
|
|
func QRCodeURL(issuer, accountName, secret string) string {
|
|
otpURL := OTPAuthURL(issuer, accountName, secret)
|
|
return fmt.Sprintf(
|
|
"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=%s",
|
|
url.QueryEscape(otpURL),
|
|
)
|
|
}
|
|
|
|
// Validate checks whether code is a valid TOTP code for secret at the current time.
|
|
// It accepts codes from [now-window*period, now+window*period] to handle clock skew.
|
|
// Handles both padded and unpadded base32 secrets.
|
|
func Validate(secret, code string) bool {
|
|
code = strings.TrimSpace(code)
|
|
if len(code) != totpDigits {
|
|
return false
|
|
}
|
|
// Normalise: uppercase, strip spaces and padding, then re-decode.
|
|
// Accept both padded (JBSWY3DP====) and unpadded (JBSWY3DP) base32.
|
|
cleaned := strings.ToUpper(strings.ReplaceAll(secret, " ", ""))
|
|
cleaned = strings.TrimRight(cleaned, "=")
|
|
keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(cleaned)
|
|
if err != nil {
|
|
log.Printf("mfa: base32 decode error (secret len=%d): %v", len(secret), err)
|
|
return false
|
|
}
|
|
now := time.Now().Unix()
|
|
counter := now / totpPeriod
|
|
for delta := int64(-totpWindow); delta <= int64(totpWindow); delta++ {
|
|
if totp(keyBytes, counter+delta) == code {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// totp computes a 6-digit TOTP for the given key and counter (RFC 6238 / HOTP RFC 4226).
|
|
func totp(key []byte, counter int64) string {
|
|
msg := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(msg, uint64(counter))
|
|
|
|
mac := hmac.New(sha1.New, key)
|
|
mac.Write(msg)
|
|
h := mac.Sum(nil)
|
|
|
|
// Dynamic truncation
|
|
offset := h[len(h)-1] & 0x0f
|
|
code := (int64(h[offset]&0x7f) << 24) |
|
|
(int64(h[offset+1]) << 16) |
|
|
(int64(h[offset+2]) << 8) |
|
|
int64(h[offset+3])
|
|
|
|
otp := code % int64(math.Pow10(totpDigits))
|
|
return fmt.Sprintf("%0*d", totpDigits, otp)
|
|
}
|