Files
Felis/internal/api/handlers_account_sessions.go
T

103 lines
3.8 KiB
Go

package api
import (
"errors"
"log"
"net/http"
"felis.lolicon.best/internal/metrics"
)
// The account holder's own sessions: every device signed in to the account,
// which one is making this request, and a way to sign any of them out. The admin
// routes in handlers_users.go read and revoke the same rows for any user.
// handleListMySessions lists the caller's live sessions, most recently seen
// first, marking the one this request came in on (GET /account/sessions). A
// caller signed in through Cloudflare Access has no session of its own, so none
// is marked.
func (a *API) handleListMySessions(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
sessions, err := a.Repo.ListUserSessions(r.Context(), p.UserID, a.now())
if err != nil {
writeError(w, r, err)
return
}
if sessions == nil {
sessions = []SessionView{}
}
if cur := callerSessionHash(r, p); cur != "" {
for i := range sessions {
sessions[i].Current = sessions[i].TokenHash == cur
}
}
writeJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
}
// handleRevokeMySession signs out one of the caller's sessions
// (DELETE /account/sessions/{hash}). A hash that is not a live session of the
// caller is 404, whoever it belongs to. Revoking the session this request came
// in on is a sign-out, so the cookie is cleared too.
func (a *API) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
hash := r.PathValue("hash")
if err := a.Repo.RevokeUserSession(r.Context(), p.UserID, hash); err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, r, newError(http.StatusNotFound, "session_not_found",
"that session has already ended or is not one of yours"))
return
}
writeError(w, r, err)
return
}
current := hash == callerSessionHash(r, p)
if current {
clearSessionCookie(w)
}
metrics.SessionsRevokedTotal.WithLabelValues("self").Inc()
a.audit(r, "account.session.revoked", "")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "signed_out": current})
}
// handleRevokeMyOtherSessions signs out every session of the caller except the
// one this request came in on (POST /account/sessions/revoke-others), and says
// how many it ended.
func (a *API) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p))
if err != nil {
writeError(w, r, err)
return
}
metrics.SessionsRevokedTotal.WithLabelValues("self").Add(float64(n))
a.audit(r, "account.session.revoked_others", "")
writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
}
// revokeOtherSessionsAfter signs out the caller's other devices after a change
// that retires a way in: a removed passkey, or a new verified email replacing the
// address sign-in codes went to. A session opened with the old factor ends with
// it. The change has already committed, so a failure here is logged and the
// request still succeeds; answering an error would invite retrying a change that
// took effect.
func (a *API) revokeOtherSessionsAfter(r *http.Request, change string) {
p := principalFromContext(r.Context())
n, err := a.Repo.RevokeOtherUserSessions(r.Context(), p.UserID, callerSessionHash(r, p))
if err != nil {
log.Printf("api: sign out other sessions after %s (request_id=%s): %v", change, requestIDFromContext(r.Context()), err)
return
}
if n > 0 {
metrics.SessionsRevokedTotal.WithLabelValues("security").Add(float64(n))
}
}
// callerSessionHash is the session the request authenticated with, or "" when it
// authenticated some other way (a cookie beside an Access JWT names nothing).
func callerSessionHash(r *http.Request, p *Principal) string {
if p == nil || !p.ViaSession {
return ""
}
return currentSessionHash(r)
}