diff --git a/server/cmd/server/auth.go b/server/cmd/server/auth.go index a8800ab..919e331 100644 --- a/server/cmd/server/auth.go +++ b/server/cmd/server/auth.go @@ -2,6 +2,7 @@ package main import ( "database/sql" + "net" "net/http" "time" @@ -136,7 +137,7 @@ func hcaCallbackHandler(client *hca.Client, q *store.Queries, authSvc *auth.Serv return } - tok, err := authSvc.IssueSession(r.Context(), user.ID) + tok, err := authSvc.IssueSession(r.Context(), user.ID, clientIP(r), r.Header.Get("User-Agent")) if err != nil { log.Error("hca: mint session", "err", err, "user_id", user.ID) loginRedirect(w, r, "session_failed") @@ -186,7 +187,7 @@ func hcaLogoutHandler(authSvc *auth.Service, secure bool) http.HandlerFunc { } } -// nullStr lifts an empty-or-not string into a sql.NullString. We use this +// nullStr lifts an empty-or-not string into a sql.NullString. // so optional HCA fields end up as NULL in SQLite rather than empty // strings, which makes "did the user have a slack id?" queries less // surprising. @@ -196,3 +197,14 @@ func nullStr(s string) sql.NullString { } return sql.NullString{String: s, Valid: true} } + +// clientIP extracts the real client IP from the request. chi's RealIP +// middleware normalises X-Forwarded-For / X-Real-IP into r.RemoteAddr, +// so we just strip the port. +func clientIP(r *http.Request) string { + ip := r.RemoteAddr + if h, _, err := net.SplitHostPort(ip); err == nil { + return h + } + return ip +} diff --git a/server/db/migrations/00004_session_meta.sql b/server/db/migrations/00004_session_meta.sql new file mode 100644 index 0000000..83e0a8e --- /dev/null +++ b/server/db/migrations/00004_session_meta.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin + +-- Capture the remote IP and User-Agent at session creation so the +-- settings page can show "Chrome on macOS · Cambridge, US" per session. +ALTER TABLE sessions ADD COLUMN ip TEXT; +ALTER TABLE sessions ADD COLUMN user_agent TEXT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN user_agent; +ALTER TABLE sessions DROP COLUMN ip; +-- +goose StatementEnd diff --git a/server/db/queries/sessions.sql b/server/db/queries/sessions.sql index dc7d90b..60cc980 100644 --- a/server/db/queries/sessions.sql +++ b/server/db/queries/sessions.sql @@ -1,6 +1,6 @@ -- name: CreateSession :one -INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at) -VALUES (?, ?, ?, ?, ?) +INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at, ip, user_agent) +VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: GetSession :one @@ -12,5 +12,11 @@ UPDATE sessions SET last_used_at = ? WHERE id = ?; -- name: DeleteSession :exec DELETE FROM sessions WHERE id = ?; +-- name: DeleteSessionForUser :exec +DELETE FROM sessions WHERE id = ? AND user_id = ?; + +-- name: ListSessionsForUser :many +SELECT * FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_used_at DESC; + -- name: DeleteExpiredSessions :exec DELETE FROM sessions WHERE expires_at <= ?; diff --git a/server/internal/api/web/web.go b/server/internal/api/web/web.go index 490f6fe..85498d1 100644 --- a/server/internal/api/web/web.go +++ b/server/internal/api/web/web.go @@ -8,6 +8,7 @@ package web import ( + "context" "encoding/json" "errors" "net/http" @@ -38,6 +39,9 @@ func (s *Server) Mount(r chi.Router) { r.Get("/balance", s.handleBalance) r.Post("/contributions", s.handleContribute) + r.Get("/sessions", s.handleListSessions) + r.Delete("/sessions/{id}", s.handleRevokeSession) + r.Get("/conversations", s.handleListConversations) r.Post("/conversations", s.handleCreateConversation) r.Get("/conversations/{id}", s.handleGetConversation) @@ -109,6 +113,82 @@ func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) { writeErr(w, 501, "not_implemented", "send amount_micros instead; client conversion pending") } +// ---- sessions ---------------------------------------------------------- + +func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { + u, _ := currentUser(r) + currentID := "" + if c, err := r.Cookie(auth.CookieName); err == nil { + currentID = auth.HashToken(c.Value) + } + rows, err := s.Q.ListSessionsForUser(r.Context(), store.ListSessionsForUserParams{ + UserID: u.ID, + ExpiresAt: time.Now().Unix(), + }) + if err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + out := make([]map[string]any, 0, len(rows)) + for _, sess := range rows { + entry := map[string]any{ + "id": sess.ID, + "created_at": sess.CreatedAt, + "last_used_at": sess.LastUsedAt, + "expires_at": sess.ExpiresAt, + "current": sess.ID == currentID, + "ip": sess.Ip.String, + "user_agent": sess.UserAgent.String, + "location": resolveIP(r.Context(), sess.Ip.String), + } + out = append(out, entry) + } + writeJSON(w, 200, out) +} + +func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) { + u, _ := currentUser(r) + id := chi.URLParam(r, "id") + if err := s.Q.DeleteSessionForUser(r.Context(), store.DeleteSessionForUserParams{ + ID: id, + UserID: u.ID, + }); err != nil { + writeErr(w, 500, "internal", err.Error()) + return + } + w.WriteHeader(204) +} + +// resolveIP calls ip.hackclub.com to get a human-readable location string +// like "Cambridge, US". Returns empty string on any failure. +func resolveIP(ctx context.Context, ip string) string { + if ip == "" || ip == "127.0.0.1" || ip == "::1" { + return "localhost" + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ip.hackclub.com/ip/"+ip, nil) + if err != nil { + return "" + } + resp, err := http.DefaultClient.Do(req) + if err != nil || resp.StatusCode != 200 { + return "" + } + defer resp.Body.Close() + var geo struct { + CityName string `json:"city_name"` + CountryCode string `json:"country_iso_code"` + } + if err := json.NewDecoder(resp.Body).Decode(&geo); err != nil { + return "" + } + if geo.CityName != "" && geo.CountryCode != "" { + return geo.CityName + ", " + geo.CountryCode + } + return geo.CountryCode +} + // ---- conversations ----------------------------------------------------- func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 2fce993..ddf29ef 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -9,6 +9,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "database/sql" "encoding/hex" "net/http" "time" @@ -34,7 +35,7 @@ func New(q *store.Queries, ttl time.Duration) *Service { // IssueSession mints a new session for userID and returns the plaintext // token to set on the client cookie. -func (s *Service) IssueSession(ctx context.Context, userID string) (string, error) { +func (s *Service) IssueSession(ctx context.Context, userID, ip, userAgent string) (string, error) { tok, err := randomToken(32) if err != nil { return "", err @@ -46,6 +47,8 @@ func (s *Service) IssueSession(ctx context.Context, userID string) (string, erro CreatedAt: now, ExpiresAt: now + int64(s.ttl.Seconds()), LastUsedAt: now, + Ip: nullStr(ip), + UserAgent: nullStr(userAgent), }) if err != nil { return "", err @@ -53,6 +56,13 @@ func (s *Service) IssueSession(ctx context.Context, userID string) (string, erro return tok, nil } +func nullStr(s string) sql.NullString { + if s == "" { + return sql.NullString{} + } + return sql.NullString{String: s, Valid: true} +} + // Middleware loads the user from the session cookie and stashes it on the // request context. Requests without a valid session pass through unauthenticated; // it's up to the handler to require auth. @@ -122,6 +132,10 @@ func hashToken(tok string) string { return hex.EncodeToString(sum[:]) } +// HashToken is the exported form for callers outside the auth package that +// need to resolve a plaintext cookie to its DB id (e.g. session listing). +func HashToken(tok string) string { return hashToken(tok) } + // RevokeSession deletes the session identified by the plaintext token. // Used by the logout handler; silently succeeds if the session is already // gone. diff --git a/server/internal/migrations/files/00004_session_meta.sql b/server/internal/migrations/files/00004_session_meta.sql new file mode 100644 index 0000000..83e0a8e --- /dev/null +++ b/server/internal/migrations/files/00004_session_meta.sql @@ -0,0 +1,15 @@ +-- +goose Up +-- +goose StatementBegin + +-- Capture the remote IP and User-Agent at session creation so the +-- settings page can show "Chrome on macOS · Cambridge, US" per session. +ALTER TABLE sessions ADD COLUMN ip TEXT; +ALTER TABLE sessions ADD COLUMN user_agent TEXT; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE sessions DROP COLUMN user_agent; +ALTER TABLE sessions DROP COLUMN ip; +-- +goose StatementEnd diff --git a/server/internal/store/models.go b/server/internal/store/models.go index cde4eb2..2b868c9 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -69,11 +69,13 @@ type ModelPrice struct { } type Session struct { - ID string `json:"id"` - UserID string `json:"user_id"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - LastUsedAt int64 `json:"last_used_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` + LastUsedAt int64 `json:"last_used_at"` + Ip sql.NullString `json:"ip"` + UserAgent sql.NullString `json:"user_agent"` } type Spend struct { diff --git a/server/internal/store/querier.go b/server/internal/store/querier.go index 43967be..0cbfa50 100644 --- a/server/internal/store/querier.go +++ b/server/internal/store/querier.go @@ -24,6 +24,7 @@ type Querier interface { DeleteExpiredIdempotency(ctx context.Context, expiresAt int64) error DeleteExpiredSessions(ctx context.Context, expiresAt int64) error DeleteSession(ctx context.Context, id string) error + DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) error GetAPIKeyByHash(ctx context.Context, keyHash string) (ApiKey, error) GetConversation(ctx context.Context, arg GetConversationParams) (Conversation, error) GetIdempotency(ctx context.Context, arg GetIdempotencyParams) (IdempotencyKey, error) @@ -41,6 +42,7 @@ type Querier interface { ListEstimatedSpends(ctx context.Context, limit int64) ([]Spend, error) ListMessagesForConversation(ctx context.Context, conversationID string) ([]Message, error) ListModelPrices(ctx context.Context) ([]ModelPrice, error) + ListSessionsForUser(ctx context.Context, arg ListSessionsForUserParams) ([]Session, error) ListStreamChunksAfter(ctx context.Context, arg ListStreamChunksAfterParams) ([]ListStreamChunksAfterRow, error) MaxStreamChunkSeq(ctx context.Context, streamID string) (interface{}, error) PoolActiveKeyCount(ctx context.Context) (int64, error) diff --git a/server/internal/store/sessions.sql.go b/server/internal/store/sessions.sql.go index 1f71cf3..be60138 100644 --- a/server/internal/store/sessions.sql.go +++ b/server/internal/store/sessions.sql.go @@ -7,20 +7,23 @@ package store import ( "context" + "database/sql" ) const createSession = `-- name: CreateSession :one -INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at) -VALUES (?, ?, ?, ?, ?) -RETURNING id, user_id, created_at, expires_at, last_used_at +INSERT INTO sessions (id, user_id, created_at, expires_at, last_used_at, ip, user_agent) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id, user_id, created_at, expires_at, last_used_at, ip, user_agent ` type CreateSessionParams struct { - ID string `json:"id"` - UserID string `json:"user_id"` - CreatedAt int64 `json:"created_at"` - ExpiresAt int64 `json:"expires_at"` - LastUsedAt int64 `json:"last_used_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + CreatedAt int64 `json:"created_at"` + ExpiresAt int64 `json:"expires_at"` + LastUsedAt int64 `json:"last_used_at"` + Ip sql.NullString `json:"ip"` + UserAgent sql.NullString `json:"user_agent"` } func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { @@ -30,6 +33,8 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S arg.CreatedAt, arg.ExpiresAt, arg.LastUsedAt, + arg.Ip, + arg.UserAgent, ) var i Session err := row.Scan( @@ -38,6 +43,8 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (S &i.CreatedAt, &i.ExpiresAt, &i.LastUsedAt, + &i.Ip, + &i.UserAgent, ) return i, err } @@ -60,8 +67,22 @@ func (q *Queries) DeleteSession(ctx context.Context, id string) error { return err } +const deleteSessionForUser = `-- name: DeleteSessionForUser :exec +DELETE FROM sessions WHERE id = ? AND user_id = ? +` + +type DeleteSessionForUserParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) error { + _, err := q.db.ExecContext(ctx, deleteSessionForUser, arg.ID, arg.UserID) + return err +} + const getSession = `-- name: GetSession :one -SELECT id, user_id, created_at, expires_at, last_used_at FROM sessions WHERE id = ? AND expires_at > ? +SELECT id, user_id, created_at, expires_at, last_used_at, ip, user_agent FROM sessions WHERE id = ? AND expires_at > ? ` type GetSessionParams struct { @@ -78,10 +99,52 @@ func (q *Queries) GetSession(ctx context.Context, arg GetSessionParams) (Session &i.CreatedAt, &i.ExpiresAt, &i.LastUsedAt, + &i.Ip, + &i.UserAgent, ) return i, err } +const listSessionsForUser = `-- name: ListSessionsForUser :many +SELECT id, user_id, created_at, expires_at, last_used_at, ip, user_agent FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_used_at DESC +` + +type ListSessionsForUserParams struct { + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` +} + +func (q *Queries) ListSessionsForUser(ctx context.Context, arg ListSessionsForUserParams) ([]Session, error) { + rows, err := q.db.QueryContext(ctx, listSessionsForUser, arg.UserID, arg.ExpiresAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Session{} + for rows.Next() { + var i Session + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.CreatedAt, + &i.ExpiresAt, + &i.LastUsedAt, + &i.Ip, + &i.UserAgent, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const touchSession = `-- name: TouchSession :exec UPDATE sessions SET last_used_at = ? WHERE id = ? ` diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9e0b860..6b42157 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -122,6 +122,20 @@ export const createKey = (name: string) => api.post('/api/keys', { name }); export const revokeKey = (id: string) => api.del(`/api/keys/${id}`); +export type Session = { + id: string; + created_at: number; + last_used_at: number; + expires_at: number; + current: boolean; + ip: string; + user_agent: string; + location: string; +}; + +export const listSessions = () => api.get('/api/sessions'); +export const revokeSession = (id: string) => api.del(`/api/sessions/${id}`); + export async function logout(): Promise { await fetch('/auth/logout', { method: 'POST', credentials: 'include' }); } diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index f51fc4b..f2f070f 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -6,9 +6,12 @@ listKeys, createKey, revokeKey, + listSessions, + revokeSession, logout, type User, - type APIKey + type APIKey, + type Session } from '$lib/api'; import { cycleTheme, currentTheme, type Theme } from '$lib/theme'; import { onMount } from 'svelte'; @@ -20,6 +23,7 @@ let user = $state(null); let bal = $state<{ balance_micros: number; balance_usd: string } | null>(null); let keys = $state([]); + let sessions = $state([]); let loading = $state(true); let err = $state(null); @@ -34,7 +38,7 @@ onMount(async () => { try { - [user, bal, keys] = await Promise.all([me(), balance(), listKeys()]); + [user, bal, keys, sessions] = await Promise.all([me(), balance(), listKeys(), listSessions()]); } catch (e: unknown) { err = e instanceof Error ? e.message : 'failed to load'; } finally { @@ -48,18 +52,60 @@ return `https://cachet.dunkirk.sh/users/${slackId}/r`; } + async function handleRevokeSession(id: string) { + try { + await revokeSession(id); + sessions = await listSessions(); + } catch { + // silently ignore + } + } + function formatDate(unix: number): string { + if (!unix) return 'never'; + const d = new Date(unix * 1000); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; return new Intl.DateTimeFormat('en-GB', { day: 'numeric', - month: 'long', - year: 'numeric' - }).format(new Date(unix * 1000)); + month: 'short', + year: diffDays > 365 ? 'numeric' : undefined + }).format(d); } function trim(usd: string) { return usd.endsWith('.00') ? usd.slice(0, -3) : usd; } + function parseUA(ua: string): string { + if (!ua) return 'unknown browser'; + const browsers: [RegExp, string][] = [ + [/Edg\//, 'Edge'], + [/OPR\//, 'Opera'], + [/Chrome\//, 'Chrome'], + [/Firefox\//, 'Firefox'], + [/Safari\//, 'Safari'], + [/curl\//, 'curl'], + ]; + const oses: [RegExp, string][] = [ + [/Windows NT/, 'Windows'], + [/Mac OS X/, 'macOS'], + [/Android/, 'Android'], + [/iPhone|iPad/, 'iOS'], + [/Linux/, 'Linux'], + ]; + const browser = browsers.find(([re]) => re.test(ua))?.[1] ?? 'browser'; + const os = oses.find(([re]) => re.test(ua))?.[1] ?? ''; + return os ? `${browser} on ${os}` : browser; + } + async function handleCreateKey(e: SubmitEvent) { e.preventDefault(); if (!newKeyName.trim()) return; @@ -198,6 +244,42 @@ {/if} + +
+
+
sessions
+
{sessions.length} active
+
+ {#if sessions.length > 0} +
    + {#each sessions as sess (sess.id)} +
  • +
    + + {parseUA(sess.user_agent)} + {#if sess.current}current{/if} + + + {#if sess.location}{sess.location} · {/if}last used {formatDate(sess.last_used_at)} + +
    + {#if sess.current} + + {:else} + + {/if} +
  • + {/each} +
+ {:else} +

no active sessions.

+ {/if} +
+
@@ -211,17 +293,6 @@
- -
-
-
sign out
-
-

ends your current session. your keys and balance stay put.

- -
- {/if} @@ -446,10 +517,12 @@ cursor: pointer; font: inherit; font-size: 0.8rem; + transition: background 80ms ease, border-color 80ms ease, color 80ms ease; } .key-revoke:hover { + background: var(--accent); border-color: var(--accent); - color: var(--accent); + color: var(--text-on-accent); } .key-tag { font-size: 0.78rem; @@ -490,27 +563,24 @@ color: var(--accent); } - /* ── sign out ─────────────────────────────────────────────────── */ - .danger-zone { - border-color: var(--border); - } - .signout-btn { - align-self: flex-start; - background: transparent; - border: 1px solid var(--border); - color: var(--text); - padding: 0.45rem 1rem; - border-radius: var(--radius-sm); - cursor: pointer; - font: inherit; - font-size: 0.9rem; - transition: border-color 80ms ease, color 80ms ease; + .sess-current { + display: inline-block; + margin-left: 0.4rem; + font-family: var(--font-mono); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--accent-eyebrow, var(--accent)); + vertical-align: middle; } - .signout-btn:hover:not(:disabled) { + + /* ── sign out (inline in sessions row) ───────────────────────── */ + .signout-inline:hover:not(:disabled) { + background: var(--accent); border-color: var(--accent); - color: var(--accent); + color: var(--text-on-accent); } - .signout-btn:disabled { + .signout-inline:disabled { opacity: 0.5; cursor: not-allowed; }