tenseleyflow/shithub / e4fdfc3

Browse files

S10: login restore-on-login hook + audit actions for account_deleted/restored

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e4fdfc3a53679a3f1022d88c98dcac68db2e9049
Parents
f080f02
Tree
e058fc8

3 changed files

StatusFile+-
M internal/auth/audit/audit.go 2 0
M internal/web/handlers/auth/auth.go 23 1
M internal/web/handlers/auth/auth_test.go 14 1
internal/auth/audit/audit.gomodified
@@ -45,6 +45,8 @@ const (
4545
 	ActionPATCreated           Action = "pat_created"
4646
 	ActionPATRevoked           Action = "pat_revoked"
4747
 	ActionUsernameChanged      Action = "username_changed"
48
+	ActionAccountDeleted       Action = "account_deleted"
49
+	ActionAccountRestored      Action = "account_restored"
4850
 )
4951
 
5052
 // Target is a typed target-type constant.
internal/web/handlers/auth/auth.gomodified
@@ -149,6 +149,8 @@ func (h *Handlers) Mount(r chi.Router) {
149149
 			r.Post("/settings/notifications", h.settingsNotificationsSubmit)
150150
 			r.Get("/settings/sessions", h.settingsSessionsList)
151151
 			r.Post("/settings/sessions/logout-everywhere", h.settingsSessionsLogoutAll)
152
+			r.Get("/settings/danger", h.settingsDangerForm)
153
+			r.Post("/settings/danger", h.settingsDangerDelete)
152154
 			r.Get("/settings/keys", h.sshKeysList)
153155
 			r.Post("/settings/keys", h.sshKeysAdd)
154156
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
@@ -379,13 +381,21 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
379381
 		return
380382
 	}
381383
 
382
-	user, err := h.q.GetUserByUsername(r.Context(), h.d.Pool, username)
384
+	// IncludingDeleted lets us spot soft-deleted users so we can restore
385
+	// them on login during the grace window. Past the window they look
386
+	// indistinguishable from "doesn't exist" — same response, same timing.
387
+	user, err := h.q.GetUserByUsernameIncludingDeleted(r.Context(), h.d.Pool, username)
383388
 	if err != nil {
384389
 		// User doesn't exist — still hash to keep timing constant.
385390
 		password.VerifyAgainstDummy(pw)
386391
 		render("Incorrect username or password.")
387392
 		return
388393
 	}
394
+	if user.DeletedAt.Valid && time.Since(user.DeletedAt.Time) >= deletionGraceWindow {
395
+		password.VerifyAgainstDummy(pw)
396
+		render("Incorrect username or password.")
397
+		return
398
+	}
389399
 
390400
 	ok, err := password.Verify(pw, user.PasswordHash)
391401
 	if err != nil {
@@ -397,6 +407,18 @@ func (h *Handlers) loginSubmit(w http.ResponseWriter, r *http.Request) {
397407
 		render("Incorrect username or password.")
398408
 		return
399409
 	}
410
+
411
+	// Restore-on-login: a within-grace soft-deleted user gets undeleted
412
+	// the moment they prove ownership of the password. Best-effort: a
413
+	// failed restore doesn't block the login (the row stays
414
+	// soft-deleted; UI will surface the issue).
415
+	if user.DeletedAt.Valid {
416
+		if err := h.q.RestoreUserAccount(r.Context(), h.d.Pool, user.ID); err != nil {
417
+			h.d.Logger.WarnContext(r.Context(), "login: restore", "error", err)
418
+		} else {
419
+			user.DeletedAt.Valid = false
420
+		}
421
+	}
400422
 	if user.SuspendedAt.Valid {
401423
 		render("This account has been suspended.")
402424
 		return
internal/web/handlers/auth/auth_test.gomodified
@@ -20,6 +20,7 @@ import (
2020
 	"time"
2121
 
2222
 	"github.com/go-chi/chi/v5"
23
+	"github.com/jackc/pgx/v5/pgxpool"
2324
 	"github.com/justinas/nosurf"
2425
 
2526
 	"github.com/tenseleyFlow/shithub/internal/auth/email"
@@ -67,6 +68,16 @@ func (c *captureSender) reset() {
6768
 var fastArgon = password.Params{Memory: 16 * 1024, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
6869
 
6970
 func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *captureSender) {
71
+	srv, _, captor := newTestServerWithPool(t, requireVerify)
72
+	return srv, captor
73
+}
74
+
75
+// newTestServerWithPool is identical to newTestServer but also exposes
76
+// the underlying pool so tests that need to manipulate DB state (e.g.
77
+// backdating timestamps) can do so against the SAME database the server
78
+// is reading from. Use the simpler newTestServer when no DB poking is
79
+// needed.
80
+func newTestServerWithPool(t *testing.T, requireVerify bool) (*httptest.Server, *pgxpool.Pool, *captureSender) {
7081
 	t.Helper()
7182
 	pool := dbtest.NewTestDB(t)
7283
 
@@ -151,7 +162,7 @@ func newTestServer(t *testing.T, requireVerify bool) (*httptest.Server, *capture
151162
 
152163
 	srv := httptest.NewServer(r)
153164
 	t.Cleanup(srv.Close)
154
-	return srv, cap
165
+	return srv, pool, cap
155166
 }
156167
 
157168
 // authTemplatesFS returns a minimal templates FS sufficient for the auth
@@ -180,6 +191,7 @@ func authTemplatesFS() fs.FS {
180191
 	emailsTpl := `{{ define "page" }}<h1>Emails</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/emails" method=POST><input name=csrf_token value="{{.CSRFToken}}"></form>EMAILS={{ range .Emails }}{{.ID}}:{{.Email}}:p={{.IsPrimary}}:v={{.Verified}};{{ end }}{{ end }}`
181192
 	notifTpl := `{{ define "page" }}<h1>Notifications</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/notifications" method=POST><input name=csrf_token value="{{.CSRFToken}}">CHANNELS={{ range .Channels }}{{.Key}}:e={{.Enabled}}:r={{.Required}};{{ end }}</form>{{ end }}`
182193
 	sessTpl := `{{ define "page" }}<h1>Sessions</h1>{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/sessions/logout-everywhere" method=POST><input name=csrf_token value="{{.CSRFToken}}">UA={{.UserAgent}};</form>{{ end }}`
194
+	dangerTpl := `{{ define "page" }}<h1>Delete</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}<form action="/settings/danger" method=POST><input name=csrf_token value="{{.CSRFToken}}">USER={{.Username}};GRACE={{.GraceWindowDays}};</form>{{ end }}`
183195
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
184196
 	return fstest.MapFS{
185197
 		"_layout.html":                {Data: []byte(layout)},
@@ -202,6 +214,7 @@ func authTemplatesFS() fs.FS {
202214
 		"settings/emails.html":        {Data: []byte(emailsTpl)},
203215
 		"settings/notifications.html": {Data: []byte(notifTpl)},
204216
 		"settings/sessions.html":      {Data: []byte(sessTpl)},
217
+		"settings/danger.html":        {Data: []byte(dangerTpl)},
205218
 		"errors/404.html":             {Data: []byte(errorPage)},
206219
 		"errors/403.html":             {Data: []byte(errorPage)},
207220
 		"errors/429.html":             {Data: []byte(errorPage)},