151 lines
3.7 KiB
Go
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)
|
|
}
|