Go · 3818 bytes Raw Blame History
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 }
106