Files
2026-10-13 00:25:29 +03:00

151 lines
3.7 KiB
Go

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