tenseleyflow/shithub / bf19c81

Browse files

S10: Danger zone — soft-delete with 14d grace window

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
bf19c81e469f5ee036c8ef1caee7865a41a7957b
Parents
e4fdfc3
Tree
35b46f1

3 changed files

StatusFile+-
A internal/web/handlers/auth/danger.go 105 0
A internal/web/handlers/auth/danger_test.go 155 0
A internal/web/templates/settings/danger.html 30 0
internal/web/handlers/auth/danger.goadded
@@ -0,0 +1,105 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+	"strings"
8
+	"time"
9
+
10
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
11
+	"github.com/tenseleyFlow/shithub/internal/auth/password"
12
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
13
+)
14
+
15
+// deletionGraceWindow is the period during which a soft-deleted account
16
+// can be restored simply by signing in. Beyond this point the row stays
17
+// soft-deleted and the login path treats the user as nonexistent.
18
+const deletionGraceWindow = 14 * 24 * time.Hour
19
+
20
+// settingsDangerForm renders GET /settings/danger.
21
+func (h *Handlers) settingsDangerForm(w http.ResponseWriter, r *http.Request) {
22
+	h.renderDangerForm(w, r, "")
23
+}
24
+
25
+// settingsDangerDelete handles POST /settings/danger.
26
+//
27
+// Requires the user to retype their username and current password so a
28
+// stolen session can't trigger this. On success: SoftDeleteUser, audit,
29
+// bump session_epoch (kicks every other live session), clear THIS
30
+// session cookie, redirect to a goodbye page.
31
+func (h *Handlers) settingsDangerDelete(w http.ResponseWriter, r *http.Request) {
32
+	if err := r.ParseForm(); err != nil {
33
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
34
+		return
35
+	}
36
+	user := middleware.CurrentUserFromContext(r.Context())
37
+	confirmName := strings.ToLower(strings.TrimSpace(r.PostFormValue("confirm_username")))
38
+	pw := r.PostFormValue("password")
39
+
40
+	if confirmName != user.Username {
41
+		h.renderDangerForm(w, r, "Type your username exactly to confirm.")
42
+		return
43
+	}
44
+	row, err := h.q.GetUserByID(r.Context(), h.d.Pool, user.ID)
45
+	if err != nil {
46
+		h.d.Logger.ErrorContext(r.Context(), "danger: load user", "error", err)
47
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
48
+		return
49
+	}
50
+	ok, err := password.Verify(pw, row.PasswordHash)
51
+	if err != nil || !ok {
52
+		h.renderDangerForm(w, r, "Password is incorrect.")
53
+		return
54
+	}
55
+
56
+	tx, err := h.d.Pool.Begin(r.Context())
57
+	if err != nil {
58
+		h.d.Logger.ErrorContext(r.Context(), "danger: begin", "error", err)
59
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
60
+		return
61
+	}
62
+	defer func() { _ = tx.Rollback(r.Context()) }()
63
+
64
+	if err := h.q.SoftDeleteUser(r.Context(), tx, user.ID); err != nil {
65
+		h.d.Logger.ErrorContext(r.Context(), "danger: soft delete", "error", err)
66
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
67
+		return
68
+	}
69
+	if err := h.q.BumpUserSessionEpoch(r.Context(), tx, user.ID); err != nil {
70
+		h.d.Logger.ErrorContext(r.Context(), "danger: bump epoch", "error", err)
71
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
72
+		return
73
+	}
74
+	if err := tx.Commit(r.Context()); err != nil {
75
+		h.d.Logger.ErrorContext(r.Context(), "danger: commit", "error", err)
76
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
77
+		return
78
+	}
79
+
80
+	if err := h.d.Audit.Record(r.Context(), h.d.Pool, user.ID,
81
+		audit.ActionAccountDeleted, audit.TargetUser, user.ID, map[string]any{
82
+			"grace_days": int(deletionGraceWindow / (24 * time.Hour)),
83
+		}); err != nil {
84
+		h.d.Logger.WarnContext(r.Context(), "danger: audit", "error", err)
85
+	}
86
+
87
+	// Sign out THIS browser. Even if the cookie weren't cleared, the
88
+	// epoch bump above would invalidate it on the next request.
89
+	h.d.SessionStore.Clear(w)
90
+
91
+	http.Redirect(w, r, "/?notice=account-deleted", http.StatusSeeOther)
92
+}
93
+
94
+// renderDangerForm is the shared render path.
95
+func (h *Handlers) renderDangerForm(w http.ResponseWriter, r *http.Request, errMsg string) {
96
+	user := middleware.CurrentUserFromContext(r.Context())
97
+	h.renderPage(w, r, "settings/danger", map[string]any{
98
+		"Title":           "Delete account",
99
+		"CSRFToken":       middleware.CSRFTokenForRequest(r),
100
+		"SettingsActive":  "danger",
101
+		"Username":        user.Username,
102
+		"GraceWindowDays": int(deletionGraceWindow / (24 * time.Hour)),
103
+		"Error":           errMsg,
104
+	})
105
+}
internal/web/handlers/auth/danger_test.goadded
@@ -0,0 +1,155 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth_test
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"net/http"
9
+	"net/url"
10
+	"strings"
11
+	"testing"
12
+	"time"
13
+
14
+	"github.com/jackc/pgx/v5/pgxpool"
15
+)
16
+
17
+func loginDangerUser(t *testing.T, name string) (cli *client, pool *pgxpool.Pool, captor *captureSender) {
18
+	t.Helper()
19
+	httpsrv, pool, captor := newTestServerWithPool(t, false)
20
+	cli = newClient(t, httpsrv)
21
+	mustSignup(t, cli, name, name+"@example.com", "correct horse battery staple")
22
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
23
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
24
+
25
+	csrf := cli.extractCSRF(t, "/login")
26
+	resp := cli.post(t, "/login", url.Values{
27
+		"csrf_token": {csrf},
28
+		"username":   {name},
29
+		"password":   {"correct horse battery staple"},
30
+	})
31
+	if resp.StatusCode != http.StatusSeeOther {
32
+		t.Fatalf("login: %d", resp.StatusCode)
33
+	}
34
+	_ = resp.Body.Close()
35
+	return cli, pool, captor
36
+}
37
+
38
+func TestDanger_DeleteRoundtripAndRestore(t *testing.T) {
39
+	t.Parallel()
40
+	cli, _, _ := loginDangerUser(t, "danga")
41
+
42
+	// Wrong username should be rejected.
43
+	csrf := cli.extractCSRF(t, "/settings/danger")
44
+	resp := cli.post(t, "/settings/danger", url.Values{
45
+		"csrf_token":       {csrf},
46
+		"confirm_username": {"not-me"},
47
+		"password":         {"correct horse battery staple"},
48
+	})
49
+	body, _ := io.ReadAll(resp.Body)
50
+	_ = resp.Body.Close()
51
+	if !strings.Contains(string(body), "Type your username") {
52
+		t.Fatalf("expected confirm-username error, got: %s", body)
53
+	}
54
+
55
+	// Wrong password should be rejected.
56
+	csrf = cli.extractCSRF(t, "/settings/danger")
57
+	resp = cli.post(t, "/settings/danger", url.Values{
58
+		"csrf_token":       {csrf},
59
+		"confirm_username": {"danga"},
60
+		"password":         {"definitely-not-the-password"},
61
+	})
62
+	body, _ = io.ReadAll(resp.Body)
63
+	_ = resp.Body.Close()
64
+	if !strings.Contains(string(body), "Password is incorrect") {
65
+		t.Fatalf("expected password error, got: %s", body)
66
+	}
67
+
68
+	// Correct credentials -> 303 to "/?notice=account-deleted".
69
+	csrf = cli.extractCSRF(t, "/settings/danger")
70
+	resp = cli.post(t, "/settings/danger", url.Values{
71
+		"csrf_token":       {csrf},
72
+		"confirm_username": {"danga"},
73
+		"password":         {"correct horse battery staple"},
74
+	})
75
+	if resp.StatusCode != http.StatusSeeOther {
76
+		body, _ := io.ReadAll(resp.Body)
77
+		t.Fatalf("delete: status=%d body=%s", resp.StatusCode, body)
78
+	}
79
+	if loc := resp.Header.Get("Location"); !strings.Contains(loc, "account-deleted") {
80
+		t.Fatalf("Location=%q", loc)
81
+	}
82
+	_ = resp.Body.Close()
83
+
84
+	// /settings/profile is now unreachable for this client (epoch stale +
85
+	// cookie cleared). RequireUser bounces to /login.
86
+	resp = cli.get(t, "/settings/profile")
87
+	defer func() { _ = resp.Body.Close() }()
88
+	if resp.StatusCode != http.StatusSeeOther && resp.StatusCode != http.StatusFound {
89
+		t.Fatalf("post-delete /settings/profile expected redirect, got %d", resp.StatusCode)
90
+	}
91
+
92
+	// Restore-on-login: signing in again with the SAME credentials
93
+	// should clear deleted_at. The login attempt itself returns the
94
+	// usual 303 to /.
95
+	cli2 := newClient(t, cli.srv)
96
+	csrf = cli2.extractCSRF(t, "/login")
97
+	resp = cli2.post(t, "/login", url.Values{
98
+		"csrf_token": {csrf},
99
+		"username":   {"danga"},
100
+		"password":   {"correct horse battery staple"},
101
+	})
102
+	if resp.StatusCode != http.StatusSeeOther {
103
+		body, _ := io.ReadAll(resp.Body)
104
+		t.Fatalf("restore-login: status=%d body=%s", resp.StatusCode, body)
105
+	}
106
+	_ = resp.Body.Close()
107
+
108
+	// Now /settings/profile is back.
109
+	resp = cli2.get(t, "/settings/profile")
110
+	defer func() { _ = resp.Body.Close() }()
111
+	if resp.StatusCode != http.StatusOK {
112
+		t.Fatalf("post-restore /settings/profile expected 200, got %d", resp.StatusCode)
113
+	}
114
+}
115
+
116
+func TestDanger_PostGracePermanent(t *testing.T) {
117
+	t.Parallel()
118
+	cli, pool, _ := loginDangerUser(t, "dangb")
119
+
120
+	// Delete normally.
121
+	csrf := cli.extractCSRF(t, "/settings/danger")
122
+	resp := cli.post(t, "/settings/danger", url.Values{
123
+		"csrf_token":       {csrf},
124
+		"confirm_username": {"dangb"},
125
+		"password":         {"correct horse battery staple"},
126
+	})
127
+	if resp.StatusCode != http.StatusSeeOther {
128
+		t.Fatalf("delete: %d", resp.StatusCode)
129
+	}
130
+	_ = resp.Body.Close()
131
+
132
+	// Backdate deleted_at past the grace window so the next login should
133
+	// be treated as nonexistent. Uses the SAME pool the test server is
134
+	// reading from — a fresh dbtest.NewTestDB call would clone a brand
135
+	// new database.
136
+	if _, err := pool.Exec(context.Background(),
137
+		"UPDATE users SET deleted_at = $1 WHERE username = 'dangb'",
138
+		time.Now().Add(-30*24*time.Hour),
139
+	); err != nil {
140
+		t.Fatalf("backdate: %v", err)
141
+	}
142
+
143
+	cli2 := newClient(t, cli.srv)
144
+	csrf = cli2.extractCSRF(t, "/login")
145
+	resp = cli2.post(t, "/login", url.Values{
146
+		"csrf_token": {csrf},
147
+		"username":   {"dangb"},
148
+		"password":   {"correct horse battery staple"},
149
+	})
150
+	defer func() { _ = resp.Body.Close() }()
151
+	body, _ := io.ReadAll(resp.Body)
152
+	if !strings.Contains(string(body), "Incorrect username or password") {
153
+		t.Fatalf("expected post-grace login to be treated as wrong, got: %s", body)
154
+	}
155
+}
internal/web/templates/settings/danger.htmladded
@@ -0,0 +1,30 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Delete account</h1>
6
+
7
+    {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
8
+
9
+    <section class="shithub-settings-section shithub-settings-danger-zone">
10
+      <h2>Permanently delete <code>{{ .Username }}</code></h2>
11
+      <p>This soft-deletes your account immediately. Any session you have open is signed out.</p>
12
+      <p>You have <strong>{{ .GraceWindowDays }} days</strong> to change your mind: just sign in again with your existing username and password and we'll restore the account. After that, the row stays deleted and can no longer be recovered through the normal sign-in flow.</p>
13
+      <p>To confirm, retype your username and your current password.</p>
14
+
15
+      <form method="POST" action="/settings/danger" novalidate>
16
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
17
+        <label>
18
+          <span>Type your username (<code>{{ .Username }}</code>)</span>
19
+          <input type="text" name="confirm_username" required autocomplete="off" spellcheck="false">
20
+        </label>
21
+        <label>
22
+          <span>Current password</span>
23
+          <input type="password" name="password" required autocomplete="current-password">
24
+        </label>
25
+        <button type="submit" class="shithub-button shithub-button-danger">Delete my account</button>
26
+      </form>
27
+    </section>
28
+  </div>
29
+</div>
30
+{{- end }}