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(¤t, &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 }