feat: initial commit
This commit is contained in:
1 parent
353f62e257
commit
c6852c6f1b
62 files changed
+5911
-2
No files matched your search
@@ -0,0 +1,150 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user