tenseleyflow/shithub / f080f02

Browse files

S10: Sessions V1 — current session view + log out everywhere

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f080f0276d373c943fbffb310934fba86d0e98d2
Parents
440f2be
Tree
48e5393

4 changed files

StatusFile+-
A internal/web/handlers/auth/sessions.go 67 0
A internal/web/handlers/auth/sessions_test.go 96 0
M internal/web/static/css/shithub.css 9 0
A internal/web/templates/settings/sessions.html 29 0
internal/web/handlers/auth/sessions.goadded
@@ -0,0 +1,67 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+	"time"
8
+
9
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
10
+)
11
+
12
+// settingsSessionsList renders GET /settings/sessions.
13
+//
14
+// V1 surfaces only the current session (we use AEAD-encrypted cookies,
15
+// no server-side session table — there's no enumerable list). The page's
16
+// real value is the "Sign out everywhere" affordance, which bumps
17
+// users.session_epoch and invalidates every cookie that carries the old
18
+// epoch on its next request.
19
+func (h *Handlers) settingsSessionsList(w http.ResponseWriter, r *http.Request) {
20
+	h.renderSessionsList(w, r, "")
21
+}
22
+
23
+// settingsSessionsLogoutAll handles POST /settings/sessions/logout-everywhere.
24
+func (h *Handlers) settingsSessionsLogoutAll(w http.ResponseWriter, r *http.Request) {
25
+	user := middleware.CurrentUserFromContext(r.Context())
26
+
27
+	if err := h.q.BumpUserSessionEpoch(r.Context(), h.d.Pool, user.ID); err != nil {
28
+		h.d.Logger.ErrorContext(r.Context(), "sessions: bump", "error", err)
29
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
30
+		return
31
+	}
32
+
33
+	// Re-sync the current session's epoch so this browser doesn't sign
34
+	// itself out alongside the others.
35
+	epoch, err := h.q.GetUserSessionEpoch(r.Context(), h.d.Pool, user.ID)
36
+	if err != nil {
37
+		h.d.Logger.ErrorContext(r.Context(), "sessions: read epoch", "error", err)
38
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
39
+		return
40
+	}
41
+	s := middleware.SessionFromContext(r.Context())
42
+	s.Epoch = epoch
43
+	if err := h.d.SessionStore.Save(w, r, s); err != nil {
44
+		h.d.Logger.ErrorContext(r.Context(), "sessions: save", "error", err)
45
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
46
+		return
47
+	}
48
+
49
+	h.renderSessionsList(w, r, "Signed out of every other session.")
50
+}
51
+
52
+// renderSessionsList is the shared render path. The "current session"
53
+// row pulls IssuedAt out of the loaded session struct.
54
+func (h *Handlers) renderSessionsList(w http.ResponseWriter, r *http.Request, successMsg string) {
55
+	s := middleware.SessionFromContext(r.Context())
56
+	issued := time.Unix(s.IssuedAt, 0)
57
+
58
+	h.renderPage(w, r, "settings/sessions", map[string]any{
59
+		"Title":          "Sessions",
60
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
61
+		"SettingsActive": "sessions",
62
+		"Issued":         issued,
63
+		"UserAgent":      r.Header.Get("User-Agent"),
64
+		"ClientIP":       clientIP(r),
65
+		"Success":        successMsg,
66
+	})
67
+}
internal/web/handlers/auth/sessions_test.goadded
@@ -0,0 +1,96 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"io"
7
+	"net/http"
8
+	"net/url"
9
+	"strings"
10
+	"testing"
11
+)
12
+
13
+func loginSessionsUser(t *testing.T, name string) (cli *client, loginAgain func() *client) {
14
+	t.Helper()
15
+	httpsrv, captor := newTestServer(t, false)
16
+	cli = newClient(t, httpsrv)
17
+	mustSignup(t, cli, name, name+"@example.com", "correct horse battery staple")
18
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
19
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
20
+
21
+	doLogin := func(c *client) {
22
+		csrf := c.extractCSRF(t, "/login")
23
+		resp := c.post(t, "/login", url.Values{
24
+			"csrf_token": {csrf},
25
+			"username":   {name},
26
+			"password":   {"correct horse battery staple"},
27
+		})
28
+		if resp.StatusCode != http.StatusSeeOther {
29
+			t.Fatalf("login: %d", resp.StatusCode)
30
+		}
31
+		_ = resp.Body.Close()
32
+	}
33
+	doLogin(cli)
34
+	// loginAgain returns a fresh client that has signed in as the same
35
+	// user — useful for testing log-out-everywhere across two browsers.
36
+	loginAgain = func() *client {
37
+		c := newClient(t, httpsrv)
38
+		doLogin(c)
39
+		return c
40
+	}
41
+	return cli, loginAgain
42
+}
43
+
44
+func TestSessions_PageRenders(t *testing.T) {
45
+	t.Parallel()
46
+	cli, _ := loginSessionsUser(t, "sa")
47
+	resp := cli.get(t, "/settings/sessions")
48
+	defer func() { _ = resp.Body.Close() }()
49
+	body, _ := io.ReadAll(resp.Body)
50
+	if !strings.Contains(string(body), "Sessions") {
51
+		t.Fatalf("missing heading: %s", body)
52
+	}
53
+	if !strings.Contains(string(body), "UA=Go-http-client") {
54
+		t.Fatalf("expected User-Agent surfaced; got: %s", body)
55
+	}
56
+}
57
+
58
+func TestSessions_LogoutEverywhereInvalidatesOthers(t *testing.T) {
59
+	t.Parallel()
60
+	cliA, loginAgain := loginSessionsUser(t, "sb")
61
+	cliB := loginAgain() // a second "browser"
62
+
63
+	// Both browsers can hit the protected page initially.
64
+	resp := cliB.get(t, "/settings/profile")
65
+	if resp.StatusCode != http.StatusOK {
66
+		t.Fatalf("cliB before bump: status=%d", resp.StatusCode)
67
+	}
68
+	_ = resp.Body.Close()
69
+
70
+	// cliA bumps the epoch.
71
+	csrf := cliA.extractCSRF(t, "/settings/sessions")
72
+	resp = cliA.post(t, "/settings/sessions/logout-everywhere", url.Values{
73
+		"csrf_token": {csrf},
74
+	})
75
+	body, _ := io.ReadAll(resp.Body)
76
+	_ = resp.Body.Close()
77
+	if !strings.Contains(string(body), "Signed out of every other session") {
78
+		t.Fatalf("expected success message, got: %s", body)
79
+	}
80
+
81
+	// cliA itself stays signed in.
82
+	resp = cliA.get(t, "/settings/profile")
83
+	if resp.StatusCode != http.StatusOK {
84
+		body, _ := io.ReadAll(resp.Body)
85
+		t.Fatalf("cliA after bump should still be signed in: %d %s", resp.StatusCode, body)
86
+	}
87
+	_ = resp.Body.Close()
88
+
89
+	// cliB should now be bounced to /login on the next protected hit
90
+	// because its session carries the stale epoch.
91
+	resp = cliB.get(t, "/settings/profile")
92
+	defer func() { _ = resp.Body.Close() }()
93
+	if resp.StatusCode != http.StatusSeeOther && resp.StatusCode != http.StatusFound {
94
+		t.Fatalf("cliB after bump expected redirect, got %d", resp.StatusCode)
95
+	}
96
+}
internal/web/static/css/shithub.cssmodified
@@ -631,3 +631,12 @@ code {
631631
 .shithub-notif-row.required { background: rgba(56, 139, 253, 0.06); }
632632
 .shithub-notif-row strong { display: block; font-size: 0.95rem; }
633633
 .shithub-notif-row small { display: block; color: var(--fg-muted); font-size: 0.85rem; }
634
+
635
+.shithub-session-meta {
636
+  display: grid;
637
+  grid-template-columns: max-content 1fr;
638
+  gap: 0.25rem 1rem;
639
+  margin: 0;
640
+}
641
+.shithub-session-meta dt { color: var(--fg-muted); font-weight: 500; }
642
+.shithub-session-meta dd { margin: 0; word-break: break-all; }
internal/web/templates/settings/sessions.htmladded
@@ -0,0 +1,29 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Sessions</h1>
6
+
7
+    {{ with .Success }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
8
+
9
+    <section class="shithub-settings-section">
10
+      <h2>This session</h2>
11
+      <p>The browser you're using right now.</p>
12
+      <dl class="shithub-session-meta">
13
+        <dt>Signed in</dt><dd>{{ .Issued.Format "2006-01-02 15:04 MST" }}</dd>
14
+        <dt>From</dt><dd><code>{{ .ClientIP }}</code></dd>
15
+        <dt>User agent</dt><dd><code>{{ .UserAgent }}</code></dd>
16
+      </dl>
17
+    </section>
18
+
19
+    <section class="shithub-settings-section">
20
+      <h2>Sign out of all other sessions</h2>
21
+      <p>Use this if you've signed in on a device you no longer have, or if you suspect your account is compromised. Your current browser stays signed in.</p>
22
+      <form method="POST" action="/settings/sessions/logout-everywhere" novalidate>
23
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
24
+        <button type="submit" class="shithub-button shithub-button-danger">Sign out everywhere else</button>
25
+      </form>
26
+    </section>
27
+  </div>
28
+</div>
29
+{{- end }}