fix(auth): recover refresh rotations and configure session lifetime
This commit is contained in:
+70
-27
@@ -63,9 +63,51 @@ func (a *API) initAuth() error {
|
||||
}
|
||||
_, 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"`
|
||||
@@ -130,18 +172,34 @@ func (a *API) secureCookies(r *http.Request) bool {
|
||||
}
|
||||
return secure
|
||||
}
|
||||
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) {
|
||||
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},
|
||||
{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},
|
||||
{Name: "dsh_csrf", Value: csrf, Path: "/"},
|
||||
{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 {
|
||||
@@ -181,34 +239,19 @@ func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, 401, "authentication required")
|
||||
return
|
||||
}
|
||||
token, err := refreshToken()
|
||||
token, err := a.rotateRefresh(sid.Value, old.Value)
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix())
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
return
|
||||
}
|
||||
if n != 1 {
|
||||
var active bool
|
||||
err = a.s.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM sessions JOIN auth_refresh ON auth_refresh.sid=sessions.id WHERE sessions.id=? AND sessions.expires>?)", sid.Value, time.Now().Unix()).Scan(&active)
|
||||
if err != nil {
|
||||
fail(w, 500, "session error")
|
||||
} else if active {
|
||||
fail(w, 409, "refresh_conflict")
|
||||
} else {
|
||||
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, errAuthentication) {
|
||||
fail(w, 401, "authentication required")
|
||||
} else {
|
||||
fail(w, 500, "session error")
|
||||
}
|
||||
return
|
||||
}
|
||||
a.authCookies(w, r, sid.Value, u.ID, csrf, token)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user