package api import ( "errors" "net/http" "time" "github.com/go-chi/chi/v5" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/audit" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // errNoCurrentSession means the request authenticated but the middleware // didn't record which session did it — which should be impossible on a route // behind RequireUser. It matters because "log out everywhere else" is defined // by exclusion: without knowing which session is ours, the safe-looking // action would sign the caller out too. var errNoCurrentSession = errors.New("no session id in request context") // sessionResp is one row of the active-sessions list. // // token_hash is absent, and that is the point of storing only a hash: it // never leaves the database, so this surface can list sessions without // handing out anything that could be replayed. type sessionResp struct { ID string `json:"id"` UserAgent string `json:"user_agent"` // CreatedIP is frozen at issue time; LastIP moves with the session. The // pair is what makes a stolen token legible — same device string, but an // address the user doesn't recognise. CreatedIP string `json:"created_ip"` LastIP string `json:"last_ip"` CreatedAt time.Time `json:"created_at"` LastSeenAt time.Time `json:"last_seen_at"` // Current marks the session making this request so the UI can label it // and not offer a "log out" that signs the user out of the page they're // standing on. Current bool `json:"current"` } type revokedResp struct { Revoked int `json:"revoked"` } // handleListMySessions implements GET /api/me/sessions. func (h *handlers) handleListMySessions(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } // Absent id is tolerated here (unlike logout-others): the list still // renders, it just won't flag a current row. currentID, _ := auth.SessionIDFromContext(r.Context()) rows, err := dbq.New(h.pool).ListSessionsForUser(r.Context(), user.ID) if err != nil { h.logger.Error("list sessions: query failed", "err", err) writeErr(w, apierror.Internal(err)) return } out := make([]sessionResp, 0, len(rows)) for _, s := range rows { out = append(out, sessionResp{ ID: uuidToString(s.ID), UserAgent: s.UserAgent, CreatedIP: s.CreatedIp, LastIP: s.LastIp, CreatedAt: s.CreatedAt.Time, LastSeenAt: s.LastSeenAt.Time, Current: s.ID == currentID, }) } writeJSON(w, http.StatusOK, out) } // handleRevokeMySession implements DELETE /api/me/sessions/{id}. func (h *handlers) handleRevokeMySession(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } id, ok := parseUUID(chi.URLParam(r, "id")) if !ok { // Malformed and belongs-to-someone-else collapse to one answer on // purpose: a distinguishable response would let a caller probe // whether another user's session id exists. writeErr(w, apierror.NotFound("session")) return } n, err := dbq.New(h.pool).DeleteSessionForUser(r.Context(), dbq.DeleteSessionForUserParams{ ID: id, UserID: user.ID, }) if err != nil { h.logger.Error("revoke session: delete failed", "err", err) writeErr(w, apierror.Internal(err)) return } if n == 0 { writeErr(w, apierror.NotFound("session")) return } audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevoke, nil) w.WriteHeader(http.StatusNoContent) } // handleRevokeMyOtherSessions implements POST /api/me/sessions/logout-others. func (h *handlers) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) { user, ok := requireUser(w, r) if !ok { return } currentID, ok := auth.SessionIDFromContext(r.Context()) if !ok { // Refuse rather than guess: deleting "all but unknown" is deleting // all, which would log the caller out of the page they invoked this // from and look exactly like the attack they were defending against. h.logger.Error("revoke other sessions: no session id in context") writeErr(w, apierror.Internal(errNoCurrentSession)) return } n, err := dbq.New(h.pool).DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{ UserID: user.ID, ID: currentID, }) if err != nil { h.logger.Error("revoke other sessions: delete failed", "err", err) writeErr(w, apierror.Internal(err)) return } audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevokeOthers, nil) writeJSON(w, http.StatusOK, revokedResp{Revoked: int(n)}) }