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) }