verstak-sync-server/internal/server/sessions.go

174 lines
5.0 KiB
Go

package server
import (
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/hex"
"fmt"
"net/http"
"strings"
"time"
)
const (
sessionScopeAdmin = "admin"
sessionScopeUser = "user"
sessionLifetime = 24 * time.Hour
)
type webSession struct {
Scope string
SubjectID string
CSRFHash string
ExpiresAt time.Time
}
func randomSecret(bytes int) (string, error) {
b := make([]byte, bytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// createSession stores only hashes. The plaintext session and CSRF values are
// returned once to be placed in cookies or an API login response.
func (s *Server) createSession(scope, subjectID string) (token, csrf string, err error) {
if scope != sessionScopeAdmin && scope != sessionScopeUser {
return "", "", fmt.Errorf("unknown session scope")
}
token, err = randomSecret(32)
if err != nil {
return "", "", err
}
csrf, err = randomSecret(32)
if err != nil {
return "", "", err
}
now := time.Now().UTC()
_, err = s.db.Exec(`INSERT INTO server_sessions
(token_hash, csrf_hash, scope, subject_id, expires_at, created_at, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sha256Hex(token), sha256Hex(csrf), scope, subjectID,
now.Add(sessionLifetime).Format(time.RFC3339), now.Format(time.RFC3339), now.Format(time.RFC3339))
if err != nil {
return "", "", err
}
return token, csrf, nil
}
func (s *Server) loadSession(token, scope string) (webSession, bool) {
if token == "" {
return webSession{}, false
}
var session webSession
var storedTokenHash string
var expiresAt string
err := s.db.QueryRow(`SELECT token_hash, csrf_hash, scope, subject_id, expires_at
FROM server_sessions WHERE token_hash=? AND scope=?`, sha256Hex(token), scope).
Scan(&storedTokenHash, &session.CSRFHash, &session.Scope, &session.SubjectID, &expiresAt)
if err != nil {
return webSession{}, false
}
expires, err := time.Parse(time.RFC3339, expiresAt)
if err != nil || !time.Now().UTC().Before(expires) {
_, _ = s.db.Exec("DELETE FROM server_sessions WHERE token_hash=?", sha256Hex(token))
return webSession{}, false
}
session.ExpiresAt = expires
// The database lookup is indexed by a fixed-size hash. Keep a constant-time
// comparison at the final token-hash boundary as well.
if subtle.ConstantTimeCompare([]byte(storedTokenHash), []byte(sha256Hex(token))) != 1 {
return webSession{}, false
}
_, _ = s.db.Exec("UPDATE server_sessions SET last_seen=? WHERE token_hash=?", time.Now().UTC().Format(time.RFC3339), sha256Hex(token))
return session, true
}
func (s *Server) deleteSession(token string) error {
if token == "" {
return nil
}
_, err := s.db.Exec("DELETE FROM server_sessions WHERE token_hash=?", sha256Hex(token))
return err
}
func (s *Server) deleteSessionsForSubject(scope, subjectID string) error {
_, err := s.db.Exec("DELETE FROM server_sessions WHERE scope=? AND subject_id=?", scope, subjectID)
return err
}
func (s *Server) setSessionCookies(w http.ResponseWriter, r *http.Request, scope, token, csrf string) {
name := "user_session"
path := "/"
if scope == sessionScopeAdmin {
name = "admin_session"
path = "/admin"
}
secure := s.requestIsHTTPS(r)
http.SetCookie(w, &http.Cookie{
Name: name, Value: token, Path: path, HttpOnly: true, Secure: secure,
SameSite: http.SameSiteLaxMode, MaxAge: int(sessionLifetime.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: "csrf_token", Value: csrf, Path: path, HttpOnly: false, Secure: secure,
SameSite: http.SameSiteStrictMode, MaxAge: int(sessionLifetime.Seconds()),
})
}
func (s *Server) clearSessionCookies(w http.ResponseWriter, r *http.Request, scope string) {
name := "user_session"
path := "/"
if scope == sessionScopeAdmin {
name = "admin_session"
path = "/admin"
}
secure := s.requestIsHTTPS(r)
for _, cookieName := range []string{name, "csrf_token"} {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: path, HttpOnly: cookieName != "csrf_token", Secure: secure, MaxAge: -1})
}
}
func (s *Server) requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
if s == nil || s.cfg == nil {
return false
}
if !s.remotePeerIsTrusted(r) {
return false
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request, scope string) (webSession, bool) {
name := "user_session"
login := "/login"
if scope == sessionScopeAdmin {
name = "admin_session"
login = "/admin/login"
}
cookie, err := r.Cookie(name)
if err != nil {
http.Redirect(w, r, login, http.StatusFound)
return webSession{}, false
}
session, ok := s.loadSession(cookie.Value, scope)
if !ok {
http.Redirect(w, r, login, http.StatusFound)
return webSession{}, false
}
return session, true
}
func (s *Server) cleanupExpiredSessions() error {
_, err := s.db.Exec("DELETE FROM server_sessions WHERE expires_at <= ?", time.Now().UTC().Format(time.RFC3339))
return err
}
func isNoRows(err error) bool {
return err == sql.ErrNoRows
}