package server import ( "crypto/sha256" "crypto/subtle" "net/http" "sync" "time" "golang.org/x/crypto/bcrypt" ) const ( sessionCookie = "lpra_session" csrfCookie = "lpra_csrf" sessionTTL = 12 * time.Hour ) // hashToken returns a hex SHA-256 of a bearer token, for at-rest storage of // per-agent tokens (we never need the plaintext back, only to compare). func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return string(hexEncode(sum[:])) } func hexEncode(b []byte) []byte { const hextable = "0123456789abcdef" out := make([]byte, len(b)*2) for i, v := range b { out[i*2] = hextable[v>>4] out[i*2+1] = hextable[v&0x0f] } return out } // constantTimeEqual compares two strings without leaking length-independent timing. func constantTimeEqual(a, b string) bool { return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 } func hashPassword(pw string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) return string(h), err } func checkPassword(hash, pw string) bool { return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil } // loginLimiter throttles failed logins per client IP to blunt brute force. type loginLimiter struct { mu sync.Mutex fail map[string]*attempt } type attempt struct { count int until time.Time } func newLoginLimiter() *loginLimiter { return &loginLimiter{fail: map[string]*attempt{}} } // blocked reports whether this IP is currently in a lockout window. func (l *loginLimiter) blocked(ip string) bool { l.mu.Lock() defer l.mu.Unlock() a := l.fail[ip] return a != nil && time.Now().Before(a.until) } func (l *loginLimiter) fin(ip string, ok bool) { l.mu.Lock() defer l.mu.Unlock() if ok { delete(l.fail, ip) return } a := l.fail[ip] if a == nil { a = &attempt{} l.fail[ip] = a } a.count++ // Exponential-ish backoff after 5 failures, capped at 5 minutes. if a.count >= 5 { d := time.Duration(a.count-4) * 15 * time.Second if d > 5*time.Minute { d = 5 * time.Minute } a.until = time.Now().Add(d) } } // currentAdmin returns the logged-in admin for a request, or nil. func (s *Server) currentAdmin(r *http.Request) *Admin { c, err := r.Cookie(sessionCookie) if err != nil { return nil } a, err := s.db.SessionAdmin(c.Value) if err != nil { return nil } return a } // setSession issues a session cookie and a paired CSRF cookie. func (s *Server) setSession(w http.ResponseWriter, adminID int64) error { sid := randToken() if err := s.db.CreateSession(sid, adminID, sessionTTL); err != nil { return err } secure := s.secureCookies http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: sid, Path: "/", HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) // CSRF token is readable by templates (not HttpOnly) and echoed back in forms. http.SetCookie(w, &http.Cookie{ Name: csrfCookie, Value: randToken(), Path: "/", Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds()), }) return nil } func (s *Server) clearSession(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil { _ = s.db.DeleteSession(c.Value) } for _, name := range []string{sessionCookie, csrfCookie} { http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", MaxAge: -1}) } } // csrfToken reads the CSRF cookie value for embedding in a form. func csrfToken(r *http.Request) string { if c, err := r.Cookie(csrfCookie); err == nil { return c.Value } return "" } // checkCSRF verifies the submitted token matches the cookie (double-submit). func checkCSRF(r *http.Request) bool { want := csrfToken(r) got := r.FormValue("csrf") return want != "" && constantTimeEqual(want, got) }