174 lines
5.0 KiB
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
|
|
}
|