Files

300 lines
9.1 KiB
Go

package api
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const accessLifetime = 5 * time.Minute
var errAuthentication = errors.New("authentication required")
func (a *API) initAuth() error {
if err := os.MkdirAll(a.root, 0700); err != nil {
return err
}
path := filepath.Join(a.root, "auth-signing.key")
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err == nil {
key := make([]byte, 32)
if _, err = rand.Read(key); err == nil {
_, err = f.Write(key)
}
if err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
} else if !os.IsExist(err) {
return err
}
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 {
return errors.New("unsafe signing key permissions")
}
a.authKey, err = os.ReadFile(path)
if err != nil {
return err
}
if len(a.authKey) != 32 {
return errors.New("invalid signing key")
}
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`)
if err == nil {
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh_previous(sid TEXT PRIMARY KEY REFERENCES auth_refresh(sid) ON DELETE CASCADE, token_hash TEXT NOT NULL);`)
}
return err
}
// Derivation makes retries return the same successor without storing plaintext
// credentials. Domain and session binding prevent cross-session token reuse.
func (a *API) nextRefresh(sid, old string) string {
mac := hmac.New(sha256.New, a.authKey)
mac.Write([]byte("refresh-successor\x00" + sid + "\x00" + old))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *API) rotateRefresh(sid, old string) (string, error) {
tx, err := a.s.DB.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
var current, previous string
err = tx.QueryRow(`SELECT r.token_hash, COALESCE(p.token_hash, '')
FROM auth_refresh r JOIN sessions s ON s.id=r.sid
LEFT JOIN auth_refresh_previous p ON p.sid=r.sid
WHERE r.sid=? AND s.expires>?`, sid, time.Now().Unix()).Scan(&current, &previous)
if err != nil {
return "", err
}
hash := refreshHash(old)
next := a.nextRefresh(sid, old)
if hash == previous && refreshHash(next) == current {
return next, tx.Commit()
}
if hash != current {
return "", errAuthentication
}
if _, err = tx.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=?", refreshHash(next), sid); err != nil {
return "", err
}
if _, err = tx.Exec("INSERT INTO auth_refresh_previous(sid,token_hash) VALUES(?,?) ON CONFLICT(sid) DO UPDATE SET token_hash=excluded.token_hash", sid, hash); err != nil {
return "", err
}
return next, tx.Commit()
}
type accessClaims struct {
Subject string `json:"sub"`
Session string `json:"sid"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
Expires int64 `json:"exp"`
}
const jwtHeader = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
func (a *API) accessJWT(sid, uid string, expires time.Time) string {
payload, _ := json.Marshal(accessClaims{uid, sid, "dsh-panel", "dsh-panel", expires.Unix()})
body := jwtHeader + "." + base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, a.authKey)
mac.Write([]byte(body))
return body + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *API) verifyAccess(r *http.Request, sid, uid string) error {
if a.authErr != nil || len(a.authKey) != 32 {
return errAuthentication
}
c, err := r.Cookie("dsh_access")
if err != nil {
return errAuthentication
}
p := strings.Split(c.Value, ".")
if len(p) != 3 || p[0] != jwtHeader {
return errAuthentication
}
sig, err := base64.RawURLEncoding.DecodeString(p[2])
if err != nil {
return errAuthentication
}
mac := hmac.New(sha256.New, a.authKey)
mac.Write([]byte(p[0] + "." + p[1]))
if !hmac.Equal(sig, mac.Sum(nil)) {
return errAuthentication
}
data, err := base64.RawURLEncoding.DecodeString(p[1])
if err != nil {
return errAuthentication
}
var claims accessClaims
if json.Unmarshal(data, &claims) != nil || claims.Subject != uid || claims.Session != sid || claims.Issuer != "dsh-panel" || claims.Audience != "dsh-panel" || claims.Expires <= time.Now().Unix() || claims.Expires > time.Now().Add(accessLifetime).Unix() {
return errAuthentication
}
return nil
}
func refreshToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b), err
}
func refreshHash(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
func (a *API) secureCookies(r *http.Request) bool {
secure := r.TLS != nil
if u, err := url.Parse(a.origin); err == nil && u.Scheme == "https" {
secure = true
}
return secure
}
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) error {
// Persist recovery cookies across mobile browser process eviction, but never
// extend them beyond the database session's absolute expiry on refresh.
var expires int64
if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil {
return err
}
now := time.Now()
remaining := int(expires - now.Unix())
if remaining <= 0 {
return errAuthentication
}
for _, c := range []*http.Cookie{
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true, MaxAge: remaining},
{Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())},
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true, MaxAge: remaining},
{Name: "dsh_csrf", Value: csrf, Path: "/", MaxAge: remaining},
} {
c.Expires = time.Unix(expires, 0)
if c.Name == "dsh_access" {
c.Expires = now.Add(accessLifetime)
}
c.Secure = a.secureCookies(r)
c.SameSite = http.SameSiteLaxMode
http.SetCookie(w, c)
}
w.Header().Set("Cache-Control", "no-store")
return nil
}
func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
if a.authErr != nil {
fail(w, 503, "authentication unavailable")
return
}
sid, err := r.Cookie("dsh_session")
if err != nil {
fail(w, 401, "authentication required")
return
}
var uid, csrf string
err = a.s.DB.QueryRow("SELECT uid,csrf FROM sessions WHERE id=? AND expires>?", sid.Value, time.Now().Unix()).Scan(&uid, &csrf)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
fail(w, 401, "authentication required")
} else {
fail(w, 500, "session error")
}
return
}
u, err := a.s.UserByID(uid)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
fail(w, 401, "authentication required")
} else {
fail(w, 500, "session error")
}
return
}
old, err := r.Cookie("dsh_refresh")
if err != nil {
fail(w, 401, "authentication required")
return
}
token, err := a.rotateRefresh(sid.Value, old.Value)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, errAuthentication) {
fail(w, 401, "authentication required")
} else {
fail(w, 500, "session error")
}
return
}
if err = a.authCookies(w, r, sid.Value, u.ID, csrf, token); err != nil {
fail(w, 500, "session error")
return
}
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
}
func (a *API) accountLocked(username string) (bool, error) {
var until int64
err := a.s.DB.QueryRow("SELECT locked_until FROM auth_failures WHERE username=?", username).Scan(&until)
if err == sql.ErrNoRows {
return false, nil
}
return until > time.Now().Unix(), err
}
// RunSessionCleanup removes expired sessions, their refresh tokens and elapsed
// login locks every ten minutes; without it the sessions table grows without
// bound because SessionUser only filters rows on read.
func (a *API) RunSessionCleanup(ctx context.Context) {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
a.cleanupSessions(time.Now().Unix())
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *API) cleanupSessions(now int64) {
for _, q := range []struct{ name, sql string }{
{"refresh tokens", "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE expires<=?)"},
{"sessions", "DELETE FROM sessions WHERE expires<=?"},
{"login locks", "DELETE FROM auth_failures WHERE locked_until>0 AND locked_until<=?"},
} {
if _, err := a.s.DB.Exec(q.sql, now); err != nil {
log.Printf("session cleanup (%s): %v", q.name, err)
}
}
}
func (a *API) failedLogin(username string) error {
_, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0)
ON CONFLICT(username) DO UPDATE SET
failures=CASE WHEN locked_until>0 AND locked_until<=? THEN 1 ELSE failures+1 END,
locked_until=CASE WHEN locked_until>0 AND locked_until<=? THEN 0 WHEN failures+1>=5 THEN ? ELSE 0 END`, username, time.Now().Unix(), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix())
return err
}