Files
MyGoNavi/internal/webserver/auth.go
Syngnat 714c6fc553 feat(web-server): 新增浏览器访问认证与初始化流程
- 新增 web-server 运行模式与浏览器端运行时桥接
- 支持管理员密码、会话策略、Google Authenticator 与恢复码
- 设置中心补充浏览器访问认证状态与改密入口
- 优化初始化向导,未启用 2FA 时跳过验证器步骤

Refs #618
2026-07-04 21:50:52 +08:00

928 lines
26 KiB
Go

package webserver
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/appdata"
"github.com/skip2/go-qrcode"
"golang.org/x/crypto/argon2"
)
const (
webAuthConfigFileName = "web_auth.json"
webAuthSchemaVersion = 1
webSessionCookieName = "gonavi_web_session"
webSetupTokenTTL = 10 * time.Minute
webLoginFailureWindow = 10 * time.Minute
webLoginFailureLimit = 5
webLoginBlockDuration = 5 * time.Minute
webDefaultSessionIdleMinutes = 30
webDefaultSessionAbsoluteHours = 24 * 7
webDefaultSessionRememberDays = 7
webMinPasswordLength = 10
webTOTPPeriodSeconds = 30
webTOTPDigits = 6
)
var (
base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
errWebAuthNotConfigured = errors.New("web auth is not configured")
errWebAuthAlreadyConfigured = errors.New("web auth is already configured")
errWebAuthSetupExpired = errors.New("setup token expired")
errWebAuthInvalidSetup = errors.New("invalid setup token")
errWebAuthInvalidCredentials = errors.New("invalid credentials")
errWebAuthRateLimited = errors.New("too many login attempts")
)
type webAuthConfig struct {
SchemaVersion int `json:"schemaVersion,omitempty"`
Enabled bool `json:"enabled"`
PasswordHash string `json:"passwordHash,omitempty"`
TOTPEnabled bool `json:"totpEnabled"`
TOTPSecret string `json:"totpSecret,omitempty"`
RecoveryCodeHashes []string `json:"recoveryCodeHashes,omitempty"`
SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"`
SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"`
SessionRememberDays int `json:"sessionRememberDays,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
func normalizeWebAuthConfig(cfg webAuthConfig) webAuthConfig {
cfg.SchemaVersion = webAuthSchemaVersion
if cfg.SessionIdleMinutes < 5 || cfg.SessionIdleMinutes > 24*60 {
cfg.SessionIdleMinutes = webDefaultSessionIdleMinutes
}
if cfg.SessionAbsoluteHours < 1 || cfg.SessionAbsoluteHours > 24*30 {
cfg.SessionAbsoluteHours = webDefaultSessionAbsoluteHours
}
if cfg.SessionRememberDays < 1 || cfg.SessionRememberDays > 30 {
cfg.SessionRememberDays = webDefaultSessionRememberDays
}
if !cfg.Enabled {
cfg.PasswordHash = ""
cfg.TOTPEnabled = false
cfg.TOTPSecret = ""
cfg.RecoveryCodeHashes = nil
}
if !cfg.TOTPEnabled {
cfg.TOTPSecret = ""
cfg.RecoveryCodeHashes = nil
}
if cfg.RecoveryCodeHashes == nil {
cfg.RecoveryCodeHashes = []string{}
}
return cfg
}
func (cfg webAuthConfig) IsConfigured() bool {
return cfg.Enabled && strings.TrimSpace(cfg.PasswordHash) != ""
}
func (cfg webAuthConfig) IdleTimeout() time.Duration {
return time.Duration(cfg.SessionIdleMinutes) * time.Minute
}
func (cfg webAuthConfig) AbsoluteTimeout() time.Duration {
return time.Duration(cfg.SessionAbsoluteHours) * time.Hour
}
func (cfg webAuthConfig) RememberDuration() time.Duration {
return time.Duration(cfg.SessionRememberDays) * 24 * time.Hour
}
type webAuthStore struct {
path string
}
func newWebAuthStore(root string) *webAuthStore {
trimmed := strings.TrimSpace(root)
if trimmed == "" {
trimmed = appdata.MustResolveActiveRoot()
}
return &webAuthStore{path: filepath.Join(trimmed, webAuthConfigFileName)}
}
func (s *webAuthStore) Load() (webAuthConfig, error) {
if s == nil || strings.TrimSpace(s.path) == "" {
return normalizeWebAuthConfig(webAuthConfig{}), nil
}
data, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return normalizeWebAuthConfig(webAuthConfig{}), nil
}
return webAuthConfig{}, err
}
var cfg webAuthConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return webAuthConfig{}, err
}
return normalizeWebAuthConfig(cfg), nil
}
func (s *webAuthStore) Save(cfg webAuthConfig) error {
if s == nil || strings.TrimSpace(s.path) == "" {
return fmt.Errorf("web auth store is unavailable")
}
cfg = normalizeWebAuthConfig(cfg)
payload, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
if err := os.WriteFile(s.path, payload, 0o600); err != nil {
return err
}
return nil
}
type pendingSetup struct {
Token string
Secret string
RecoveryCodes []string
Issuer string
AccountName string
CreatedAt time.Time
}
type pendingSetupResponse struct {
SetupToken string `json:"setupToken"`
Secret string `json:"secret"`
OtpauthURL string `json:"otpauthUrl"`
QRCodeDataURL string `json:"qrCodeDataUrl,omitempty"`
Issuer string `json:"issuer"`
AccountName string `json:"accountName"`
RecoveryCodes []string `json:"recoveryCodes"`
SessionIdleMinutes int `json:"sessionIdleMinutes"`
SessionAbsoluteHours int `json:"sessionAbsoluteHours"`
SessionRememberDays int `json:"sessionRememberDays"`
}
type loginAttemptState struct {
Failures []time.Time
BlockedUntil time.Time
}
type loginAttemptTracker struct {
mu sync.Mutex
attempts map[string]loginAttemptState
}
func newLoginAttemptTracker() *loginAttemptTracker {
return &loginAttemptTracker{attempts: make(map[string]loginAttemptState)}
}
func (t *loginAttemptTracker) allow(ip string, now time.Time) (time.Duration, bool) {
normalized := strings.TrimSpace(ip)
if normalized == "" {
return 0, true
}
t.mu.Lock()
defer t.mu.Unlock()
state := t.attempts[normalized]
if state.BlockedUntil.After(now) {
return state.BlockedUntil.Sub(now), false
}
if len(state.Failures) == 0 {
delete(t.attempts, normalized)
return 0, true
}
kept := state.Failures[:0]
for _, failureAt := range state.Failures {
if now.Sub(failureAt) <= webLoginFailureWindow {
kept = append(kept, failureAt)
}
}
state.Failures = append([]time.Time(nil), kept...)
if len(state.Failures) == 0 {
delete(t.attempts, normalized)
return 0, true
}
t.attempts[normalized] = state
return 0, true
}
func (t *loginAttemptTracker) recordFailure(ip string, now time.Time) time.Duration {
normalized := strings.TrimSpace(ip)
if normalized == "" {
return 0
}
t.mu.Lock()
defer t.mu.Unlock()
state := t.attempts[normalized]
kept := state.Failures[:0]
for _, failureAt := range state.Failures {
if now.Sub(failureAt) <= webLoginFailureWindow {
kept = append(kept, failureAt)
}
}
kept = append(kept, now)
state.Failures = append([]time.Time(nil), kept...)
if len(state.Failures) >= webLoginFailureLimit {
state.Failures = nil
state.BlockedUntil = now.Add(webLoginBlockDuration)
t.attempts[normalized] = state
return webLoginBlockDuration
}
t.attempts[normalized] = state
return 0
}
func (t *loginAttemptTracker) recordSuccess(ip string) {
normalized := strings.TrimSpace(ip)
if normalized == "" {
return
}
t.mu.Lock()
delete(t.attempts, normalized)
t.mu.Unlock()
}
type webSession struct {
ID string
CreatedAt time.Time
LastSeenAt time.Time
AbsoluteDeadline time.Time
}
type webSessionManager struct {
mu sync.Mutex
sessions map[string]webSession
}
func newWebSessionManager() *webSessionManager {
return &webSessionManager{sessions: make(map[string]webSession)}
}
func (m *webSessionManager) Create(cfg webAuthConfig, now time.Time) (string, error) {
if m == nil {
return "", fmt.Errorf("web session manager is unavailable")
}
sessionID, err := generateRandomToken(32)
if err != nil {
return "", err
}
session := webSession{
ID: sessionID,
CreatedAt: now,
LastSeenAt: now,
AbsoluteDeadline: now.Add(cfg.AbsoluteTimeout()),
}
m.mu.Lock()
m.sessions[sessionID] = session
m.mu.Unlock()
return sessionID, nil
}
func (m *webSessionManager) Authenticate(sessionID string, cfg webAuthConfig, now time.Time) bool {
normalized := strings.TrimSpace(sessionID)
if m == nil || normalized == "" {
return false
}
m.mu.Lock()
defer m.mu.Unlock()
for key, session := range m.sessions {
if now.After(session.AbsoluteDeadline) || now.Sub(session.LastSeenAt) > cfg.IdleTimeout() {
delete(m.sessions, key)
}
}
session, ok := m.sessions[normalized]
if !ok {
return false
}
session.LastSeenAt = now
m.sessions[normalized] = session
return true
}
func (m *webSessionManager) Destroy(sessionID string) {
normalized := strings.TrimSpace(sessionID)
if m == nil || normalized == "" {
return
}
m.mu.Lock()
delete(m.sessions, normalized)
m.mu.Unlock()
}
func (m *webSessionManager) DestroyAll() {
if m == nil {
return
}
m.mu.Lock()
m.sessions = make(map[string]webSession)
m.mu.Unlock()
}
type webAuthManager struct {
mu sync.RWMutex
store *webAuthStore
config webAuthConfig
pending map[string]pendingSetup
sessions *webSessionManager
loginAttempts *loginAttemptTracker
now func() time.Time
}
type webAuthStatus struct {
Configured bool `json:"configured"`
Authenticated bool `json:"authenticated"`
TOTPEnabled bool `json:"totpEnabled"`
SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"`
SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"`
SessionRememberDays int `json:"sessionRememberDays,omitempty"`
}
type webAuthSettingsSummary struct {
Configured bool `json:"configured"`
TOTPEnabled bool `json:"totpEnabled"`
RecoveryCodesRemaining int `json:"recoveryCodesRemaining"`
SessionIdleMinutes int `json:"sessionIdleMinutes,omitempty"`
SessionAbsoluteHours int `json:"sessionAbsoluteHours,omitempty"`
SessionRememberDays int `json:"sessionRememberDays,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
func newWebAuthManager(root string) (*webAuthManager, error) {
store := newWebAuthStore(root)
cfg, err := store.Load()
if err != nil {
return nil, err
}
return &webAuthManager{
store: store,
config: cfg,
pending: make(map[string]pendingSetup),
sessions: newWebSessionManager(),
loginAttempts: newLoginAttemptTracker(),
now: time.Now,
}, nil
}
func (m *webAuthManager) currentConfig() webAuthConfig {
m.mu.RLock()
defer m.mu.RUnlock()
return cloneWebAuthConfig(m.config)
}
func cloneWebAuthConfig(cfg webAuthConfig) webAuthConfig {
copied := cfg
if cfg.RecoveryCodeHashes != nil {
copied.RecoveryCodeHashes = append([]string(nil), cfg.RecoveryCodeHashes...)
}
return copied
}
func buildWebAuthSettingsSummary(cfg webAuthConfig) webAuthSettingsSummary {
return webAuthSettingsSummary{
Configured: cfg.IsConfigured(),
TOTPEnabled: cfg.TOTPEnabled,
RecoveryCodesRemaining: len(cfg.RecoveryCodeHashes),
SessionIdleMinutes: cfg.SessionIdleMinutes,
SessionAbsoluteHours: cfg.SessionAbsoluteHours,
SessionRememberDays: cfg.SessionRememberDays,
UpdatedAt: strings.TrimSpace(cfg.UpdatedAt),
}
}
func (m *webAuthManager) Status(sessionID string) webAuthStatus {
cfg := m.currentConfig()
status := webAuthStatus{
Configured: cfg.IsConfigured(),
TOTPEnabled: cfg.TOTPEnabled,
SessionIdleMinutes: cfg.SessionIdleMinutes,
SessionAbsoluteHours: cfg.SessionAbsoluteHours,
SessionRememberDays: cfg.SessionRememberDays,
}
if status.Configured {
status.Authenticated = m.sessions.Authenticate(sessionID, cfg, m.now())
}
return status
}
func (m *webAuthManager) Settings() (webAuthSettingsSummary, error) {
cfg := m.currentConfig()
if !cfg.IsConfigured() {
return webAuthSettingsSummary{}, errWebAuthNotConfigured
}
return buildWebAuthSettingsSummary(cfg), nil
}
func (m *webAuthManager) BeginSetup(host string) (pendingSetupResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.config.IsConfigured() {
return pendingSetupResponse{}, errWebAuthAlreadyConfigured
}
token, err := generateRandomToken(24)
if err != nil {
return pendingSetupResponse{}, err
}
secret, err := generateTOTPSecret()
if err != nil {
return pendingSetupResponse{}, err
}
recoveryCodes, err := generateRecoveryCodes(8)
if err != nil {
return pendingSetupResponse{}, err
}
accountName := buildTOTPAccountName(host)
setup := pendingSetup{
Token: token,
Secret: secret,
RecoveryCodes: recoveryCodes,
Issuer: "GoNavi",
AccountName: accountName,
CreatedAt: m.now(),
}
now := m.now()
for key, item := range m.pending {
if now.Sub(item.CreatedAt) > webSetupTokenTTL {
delete(m.pending, key)
}
}
m.pending[token] = setup
otpauthURL := buildOtpauthURL(setup.Issuer, setup.AccountName, secret)
qrCodeDataURL, err := generateQRCodeDataURL(otpauthURL)
if err != nil {
return pendingSetupResponse{}, err
}
return pendingSetupResponse{
SetupToken: token,
Secret: secret,
OtpauthURL: otpauthURL,
QRCodeDataURL: qrCodeDataURL,
Issuer: setup.Issuer,
AccountName: setup.AccountName,
RecoveryCodes: append([]string(nil), recoveryCodes...),
SessionIdleMinutes: webDefaultSessionIdleMinutes,
SessionAbsoluteHours: webDefaultSessionAbsoluteHours,
SessionRememberDays: webDefaultSessionRememberDays,
}, nil
}
func (m *webAuthManager) CompleteSetup(token string, password string, code string, enableTOTP bool, idleMinutes int, absoluteHours int, rememberDays int) (webAuthConfig, string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.config.IsConfigured() {
return webAuthConfig{}, "", errWebAuthAlreadyConfigured
}
setup, ok := m.pending[strings.TrimSpace(token)]
if !ok {
return webAuthConfig{}, "", errWebAuthInvalidSetup
}
if m.now().Sub(setup.CreatedAt) > webSetupTokenTTL {
delete(m.pending, strings.TrimSpace(token))
return webAuthConfig{}, "", errWebAuthSetupExpired
}
normalizedPassword := strings.TrimSpace(password)
if len(normalizedPassword) < webMinPasswordLength {
return webAuthConfig{}, "", fmt.Errorf("password must be at least %d characters", webMinPasswordLength)
}
if enableTOTP && !validateTOTPCode(setup.Secret, code, m.now()) {
return webAuthConfig{}, "", fmt.Errorf("invalid google authenticator code")
}
passwordHash, err := hashPassword(normalizedPassword)
if err != nil {
return webAuthConfig{}, "", err
}
cfg := normalizeWebAuthConfig(webAuthConfig{
Enabled: true,
PasswordHash: passwordHash,
TOTPEnabled: enableTOTP,
TOTPSecret: setup.Secret,
SessionIdleMinutes: idleMinutes,
SessionAbsoluteHours: absoluteHours,
SessionRememberDays: rememberDays,
UpdatedAt: m.now().UTC().Format(time.RFC3339),
})
if enableTOTP {
cfg.RecoveryCodeHashes = make([]string, 0, len(setup.RecoveryCodes))
for _, item := range setup.RecoveryCodes {
cfg.RecoveryCodeHashes = append(cfg.RecoveryCodeHashes, hashRecoveryCode(item))
}
}
if err := m.store.Save(cfg); err != nil {
return webAuthConfig{}, "", err
}
m.config = cfg
delete(m.pending, strings.TrimSpace(token))
sessionID, err := m.sessions.Create(cfg, m.now())
if err != nil {
return webAuthConfig{}, "", err
}
return cloneWebAuthConfig(cfg), sessionID, nil
}
func (m *webAuthManager) Login(password string, code string, remoteIP string) (webAuthConfig, string, bool, time.Duration, error) {
now := m.now()
if wait, ok := m.loginAttempts.allow(remoteIP, now); !ok {
return webAuthConfig{}, "", false, wait, errWebAuthRateLimited
}
m.mu.Lock()
defer m.mu.Unlock()
cfg := cloneWebAuthConfig(m.config)
if !cfg.IsConfigured() {
return webAuthConfig{}, "", false, 0, errWebAuthNotConfigured
}
if !verifyPassword(cfg.PasswordHash, strings.TrimSpace(password)) {
wait := m.loginAttempts.recordFailure(remoteIP, now)
return webAuthConfig{}, "", false, wait, errWebAuthInvalidCredentials
}
usedRecoveryCode := false
if cfg.TOTPEnabled {
normalizedCode := normalizeUserCode(code)
if normalizedCode == "" {
wait := m.loginAttempts.recordFailure(remoteIP, now)
return webAuthConfig{}, "", false, wait, errWebAuthInvalidCredentials
}
if !validateTOTPCode(cfg.TOTPSecret, normalizedCode, now) {
nextCfg, consumed := consumeRecoveryCode(cfg, normalizedCode)
if !consumed {
wait := m.loginAttempts.recordFailure(remoteIP, now)
return webAuthConfig{}, "", false, wait, errWebAuthInvalidCredentials
}
if err := m.store.Save(nextCfg); err != nil {
return webAuthConfig{}, "", false, 0, err
}
cfg = nextCfg
m.config = nextCfg
usedRecoveryCode = true
}
}
sessionID, err := m.sessions.Create(cfg, now)
if err != nil {
return webAuthConfig{}, "", false, 0, err
}
m.loginAttempts.recordSuccess(remoteIP)
return cfg, sessionID, usedRecoveryCode, 0, nil
}
func (m *webAuthManager) Logout(sessionID string) {
m.sessions.Destroy(sessionID)
}
func (m *webAuthManager) ChangePassword(currentPassword string, code string, nextPassword string) (webAuthConfig, string, bool, error) {
now := m.now()
m.mu.Lock()
defer m.mu.Unlock()
cfg := cloneWebAuthConfig(m.config)
if !cfg.IsConfigured() {
return webAuthConfig{}, "", false, errWebAuthNotConfigured
}
if !verifyPassword(cfg.PasswordHash, strings.TrimSpace(currentPassword)) {
return webAuthConfig{}, "", false, errWebAuthInvalidCredentials
}
usedRecoveryCode := false
if cfg.TOTPEnabled {
normalizedCode := normalizeUserCode(code)
if normalizedCode == "" {
return webAuthConfig{}, "", false, errWebAuthInvalidCredentials
}
if !validateTOTPCode(cfg.TOTPSecret, normalizedCode, now) {
nextCfg, consumed := consumeRecoveryCode(cfg, normalizedCode)
if !consumed {
return webAuthConfig{}, "", false, errWebAuthInvalidCredentials
}
cfg = nextCfg
usedRecoveryCode = true
}
}
passwordHash, err := hashPassword(nextPassword)
if err != nil {
return webAuthConfig{}, "", false, err
}
cfg.PasswordHash = passwordHash
cfg.UpdatedAt = now.UTC().Format(time.RFC3339)
if err := m.store.Save(cfg); err != nil {
return webAuthConfig{}, "", false, err
}
m.config = cfg
m.sessions.DestroyAll()
sessionID, err := m.sessions.Create(cfg, now)
if err != nil {
return webAuthConfig{}, "", false, err
}
return cloneWebAuthConfig(cfg), sessionID, usedRecoveryCode, nil
}
func buildTOTPAccountName(host string) string {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return "admin@gonavi"
}
if parsedHost, _, err := net.SplitHostPort(trimmed); err == nil && strings.TrimSpace(parsedHost) != "" {
trimmed = parsedHost
}
trimmed = strings.TrimSpace(strings.Trim(trimmed, "[]"))
if trimmed == "" {
trimmed = "gonavi"
}
return "admin@" + trimmed
}
func buildOtpauthURL(issuer string, accountName string, secret string) string {
normalizedIssuer := strings.TrimSpace(issuer)
if normalizedIssuer == "" {
normalizedIssuer = "GoNavi"
}
normalizedAccountName := strings.TrimSpace(accountName)
if normalizedAccountName == "" {
normalizedAccountName = "admin@gonavi"
}
query := url.Values{}
query.Set("secret", strings.TrimSpace(secret))
query.Set("issuer", normalizedIssuer)
query.Set("algorithm", "SHA1")
query.Set("digits", strconv.Itoa(webTOTPDigits))
query.Set("period", strconv.Itoa(webTOTPPeriodSeconds))
label := url.PathEscape(normalizedIssuer + ":" + normalizedAccountName)
return "otpauth://totp/" + label + "?" + query.Encode()
}
func generateQRCodeDataURL(payload string) (string, error) {
normalized := strings.TrimSpace(payload)
if normalized == "" {
return "", fmt.Errorf("qr payload is empty")
}
png, err := qrcode.Encode(normalized, qrcode.Medium, 256)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
func generateRandomToken(length int) (string, error) {
if length <= 0 {
return "", fmt.Errorf("invalid token length")
}
buf := make([]byte, length)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func generateTOTPSecret() (string, error) {
buf := make([]byte, 20)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base32NoPadding.EncodeToString(buf), nil
}
func generateRecoveryCodes(count int) ([]string, error) {
if count <= 0 {
return []string{}, nil
}
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
result := make([]string, 0, count)
random := make([]byte, count*12)
if _, err := rand.Read(random); err != nil {
return nil, err
}
for index := 0; index < count; index++ {
chunk := random[index*12 : (index+1)*12]
var builder strings.Builder
builder.Grow(14)
for offset, value := range chunk {
if offset == 4 || offset == 8 {
builder.WriteByte('-')
}
builder.WriteByte(alphabet[int(value)%len(alphabet)])
}
result = append(result, builder.String())
}
return result, nil
}
func hashRecoveryCode(code string) string {
sum := sha256.Sum256([]byte(normalizeUserCode(code)))
return hex.EncodeToString(sum[:])
}
func consumeRecoveryCode(cfg webAuthConfig, code string) (webAuthConfig, bool) {
if !cfg.TOTPEnabled || len(cfg.RecoveryCodeHashes) == 0 {
return cfg, false
}
needle := hashRecoveryCode(code)
next := cloneWebAuthConfig(cfg)
next.RecoveryCodeHashes = next.RecoveryCodeHashes[:0]
consumed := false
for _, item := range cfg.RecoveryCodeHashes {
if subtle.ConstantTimeCompare([]byte(item), []byte(needle)) == 1 && !consumed {
consumed = true
continue
}
next.RecoveryCodeHashes = append(next.RecoveryCodeHashes, item)
}
return next, consumed
}
func normalizeUserCode(code string) string {
replacer := strings.NewReplacer(" ", "", "-", "", "\t", "", "\r", "", "\n", "")
return strings.ToUpper(strings.TrimSpace(replacer.Replace(code)))
}
func validateTOTPCode(secret string, code string, now time.Time) bool {
normalizedSecret := strings.ToUpper(strings.TrimSpace(secret))
normalizedCode := normalizeUserCode(code)
if normalizedSecret == "" || normalizedCode == "" {
return false
}
for offset := -1; offset <= 1; offset++ {
expected, err := generateTOTPCodeAt(normalizedSecret, now.Add(time.Duration(offset)*webTOTPPeriodSeconds*time.Second))
if err != nil {
return false
}
if subtle.ConstantTimeCompare([]byte(expected), []byte(normalizedCode)) == 1 {
return true
}
}
return false
}
func generateTOTPCodeAt(secret string, now time.Time) (string, error) {
key, err := base32NoPadding.DecodeString(strings.ToUpper(strings.TrimSpace(secret)))
if err != nil {
return "", err
}
counter := uint64(now.Unix() / webTOTPPeriodSeconds)
var counterBytes [8]byte
binary.BigEndian.PutUint64(counterBytes[:], counter)
mac := hmac.New(sha1.New, key)
_, _ = mac.Write(counterBytes[:])
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
binaryCode := (int(sum[offset])&0x7f)<<24 |
(int(sum[offset+1])&0xff)<<16 |
(int(sum[offset+2])&0xff)<<8 |
(int(sum[offset+3]) & 0xff)
code := binaryCode % 1000000
return fmt.Sprintf("%06d", code), nil
}
func hashPassword(password string) (string, error) {
normalized := strings.TrimSpace(password)
if normalized == "" {
return "", fmt.Errorf("password is required")
}
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
const (
timeCost uint32 = 3
memoryCost uint32 = 64 * 1024
parallelism uint8 = 2
keyLen uint32 = 32
)
key := argon2.IDKey([]byte(normalized), salt, timeCost, memoryCost, parallelism, keyLen)
return fmt.Sprintf(
"argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
memoryCost,
timeCost,
parallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
func verifyPassword(encodedHash string, password string) bool {
parts := strings.Split(strings.TrimSpace(encodedHash), "$")
if len(parts) != 5 || parts[0] != "argon2id" || parts[1] != "v=19" {
return false
}
params := strings.Split(parts[2], ",")
if len(params) != 3 {
return false
}
memoryText := strings.TrimPrefix(params[0], "m=")
timeText := strings.TrimPrefix(params[1], "t=")
parallelText := strings.TrimPrefix(params[2], "p=")
memoryCost, err := strconv.ParseUint(memoryText, 10, 32)
if err != nil {
return false
}
timeCost, err := strconv.ParseUint(timeText, 10, 32)
if err != nil {
return false
}
parallelism, err := strconv.ParseUint(parallelText, 10, 8)
if err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
expectedKey, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
derivedKey := argon2.IDKey(
[]byte(strings.TrimSpace(password)),
salt,
uint32(timeCost),
uint32(memoryCost),
uint8(parallelism),
uint32(len(expectedKey)),
)
return subtle.ConstantTimeCompare(derivedKey, expectedKey) == 1
}
func readSessionCookie(r *http.Request) (string, bool) {
if r == nil {
return "", false
}
cookie, err := r.Cookie(webSessionCookieName)
if err != nil {
return "", false
}
value := strings.TrimSpace(cookie.Value)
return value, value != ""
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string, cfg webAuthConfig, now time.Time) {
http.SetCookie(w, &http.Cookie{
Name: webSessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
Expires: now.Add(cfg.RememberDuration()),
MaxAge: int(cfg.RememberDuration().Seconds()),
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: webSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
func isSecureRequest(r *http.Request) bool {
if r == nil {
return false
}
if r.TLS != nil {
return true
}
if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Ssl")), "on")
}
func clientIP(r *http.Request) string {
if r == nil {
return ""
}
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
return forwarded
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil && strings.TrimSpace(host) != "" {
return host
}
return strings.TrimSpace(r.RemoteAddr)
}