tenseleyflow/shithub / 82087a7

Browse files

S10: password change at /settings/password (current pw + 2FA gate + session-epoch bump)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
82087a7077aec4f2dd2448c6a6c6d904769d8397
Parents
ad881a7
Tree
0a894d6

5 changed files

StatusFile+-
M internal/web/handlers/auth/auth.go 2 0
M internal/web/handlers/auth/auth_test.go 3 0
A internal/web/handlers/auth/password_change.go 135 0
A internal/web/handlers/auth/password_change_test.go 152 0
A internal/web/templates/settings/password.html 40 0
internal/web/handlers/auth/auth.gomodified
@@ -136,6 +136,8 @@ func (h *Handlers) Mount(r chi.Router) {
136136
 			}
137137
 			r.Get("/settings/account", h.settingsAccountForm)
138138
 			r.Post("/settings/account/username", h.settingsAccountUsername)
139
+			r.Get("/settings/password", h.settingsPasswordForm)
140
+			r.Post("/settings/password", h.settingsPasswordSubmit)
139141
 			r.Get("/settings/keys", h.sshKeysList)
140142
 			r.Post("/settings/keys", h.sshKeysAdd)
141143
 			r.Post("/settings/keys/{id}/delete", h.sshKeysDelete)
internal/web/handlers/auth/auth_test.gomodified
@@ -164,6 +164,8 @@ func authTemplatesFS() fs.FS {
164164
 	tokensTpl := `{{ define "page" }}<form>{{ with .CreateError }}<p class=error>{{.}}</p>{{ end }}<input name=csrf_token value="{{.CSRFToken}}">{{ if .JustCreatedRaw }}RAW={{.JustCreatedRaw}}{{ end }}TOKENS={{ range .Tokens }}{{.ID}}:{{.TokenPrefix}}{{ if .RevokedAt.Valid }}:revoked{{ end }};{{ end }}</form>{{ end }}`
165165
 	profileTpl := `{{ define "page" }}<h1>Public profile</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form><input name=csrf_token value="{{.CSRFToken}}">DISPLAY={{.Form.DisplayName}};BIO={{.Form.Bio}};LOCATION={{.Form.Location}};WEBSITE={{.Form.Website}};COMPANY={{.Form.Company}};PRONOUNS={{.Form.Pronouns}};</form>{{ if .HasAvatar }}<form action="/settings/profile/avatar/remove" method=POST><input name=csrf_token value="{{.CSRFToken}}"><button>Remove</button></form>{{ end }}{{ end }}`
166166
 	accountTpl := `{{ define "page" }}<h1>Account</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/account/username" method=POST><input name=csrf_token value="{{.CSRFToken}}">USERNAME={{.CurrentUsername}};USED={{.RecentRenames}}/{{.MaxRenames}};</form>{{ end }}`
167
+	//nolint:gosec // G101 false positive: HTML fixture, not a credential.
168
+	pwTpl := `{{ define "page" }}<h1>Password</h1>{{ with .Error }}<p class=error>{{.}}</p>{{ end }}{{ with .Success }}<p class=notice>{{.}}</p>{{ end }}<form action="/settings/password" method=POST><input name=csrf_token value="{{.CSRFToken}}">RECENT={{.RecentAuthOK}};</form>{{ end }}`
167169
 	errorPage := `{{ define "page" }}<h1>{{.Status}} {{.StatusText}}</h1><p>{{.Message}}</p>{{ end }}`
168170
 	return fstest.MapFS{
169171
 		"_layout.html":               {Data: []byte(layout)},
@@ -181,6 +183,7 @@ func authTemplatesFS() fs.FS {
181183
 		"settings/tokens.html":       {Data: []byte(tokensTpl)},
182184
 		"settings/profile.html":      {Data: []byte(profileTpl)},
183185
 		"settings/account.html":      {Data: []byte(accountTpl)},
186
+		"settings/password.html":     {Data: []byte(pwTpl)},
184187
 		"errors/404.html":            {Data: []byte(errorPage)},
185188
 		"errors/403.html":            {Data: []byte(errorPage)},
186189
 		"errors/429.html":            {Data: []byte(errorPage)},
internal/web/handlers/auth/password_change.goadded
@@ -0,0 +1,135 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package auth
4
+
5
+import (
6
+	"net/http"
7
+
8
+	"github.com/tenseleyFlow/shithub/internal/auth/audit"
9
+	"github.com/tenseleyFlow/shithub/internal/auth/password"
10
+	"github.com/tenseleyFlow/shithub/internal/passwords"
11
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
12
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
13
+)
14
+
15
+// settingsPasswordForm renders GET /settings/password.
16
+func (h *Handlers) settingsPasswordForm(w http.ResponseWriter, r *http.Request) {
17
+	h.renderPasswordForm(w, r, "", "")
18
+}
19
+
20
+// settingsPasswordSubmit handles POST /settings/password.
21
+//
22
+// Flow:
23
+//  1. Recent-2FA gate (handlers/auth/tokens.go::recentAuthOK semantics).
24
+//     Skipped for users without 2FA enrolled.
25
+//  2. Verify the current password.
26
+//  3. Validate the new password against the same rules as signup.
27
+//  4. Hash + UpdateUserPassword + bump session_epoch (the bump invalidates
28
+//     all OTHER sessions on the account; the current session sticks
29
+//     because we re-issue its cookie below).
30
+//  5. Audit-log + (later) email notification.
31
+func (h *Handlers) settingsPasswordSubmit(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
+	current := r.PostFormValue("current_password")
38
+	next := r.PostFormValue("new_password")
39
+	confirm := r.PostFormValue("confirm_password")
40
+
41
+	if !h.recentAuthOK(r) {
42
+		h.renderPasswordForm(w, r,
43
+			"Confirm 2FA recently before changing your password: sign in again with your authenticator code.",
44
+			"")
45
+		return
46
+	}
47
+
48
+	row, err := h.q.GetUserByID(r.Context(), h.d.Pool, user.ID)
49
+	if err != nil {
50
+		h.d.Logger.ErrorContext(r.Context(), "password: load user", "error", err)
51
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
52
+		return
53
+	}
54
+	ok, err := password.Verify(current, row.PasswordHash)
55
+	if err != nil || !ok {
56
+		h.renderPasswordForm(w, r, "Current password is incorrect.", "")
57
+		return
58
+	}
59
+
60
+	if msg := validateNewPassword(next, confirm); msg != "" {
61
+		h.renderPasswordForm(w, r, msg, "")
62
+		return
63
+	}
64
+	if next == current {
65
+		h.renderPasswordForm(w, r, "Pick a password different from your current one.", "")
66
+		return
67
+	}
68
+
69
+	hash, err := hashPassword(next, h.d.Argon2)
70
+	if err != nil {
71
+		h.d.Logger.ErrorContext(r.Context(), "password: hash", "error", err)
72
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
73
+		return
74
+	}
75
+
76
+	tx, err := h.d.Pool.Begin(r.Context())
77
+	if err != nil {
78
+		h.d.Logger.ErrorContext(r.Context(), "password: begin", "error", err)
79
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
80
+		return
81
+	}
82
+	defer func() { _ = tx.Rollback(r.Context()) }()
83
+
84
+	if err := h.q.UpdateUserPassword(r.Context(), tx, usersdb.UpdateUserPasswordParams{
85
+		ID: user.ID, PasswordHash: hash, PasswordAlgo: "argon2id-v1",
86
+	}); err != nil {
87
+		h.d.Logger.ErrorContext(r.Context(), "password: update", "error", err)
88
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
89
+		return
90
+	}
91
+	if err := h.q.BumpUserSessionEpoch(r.Context(), tx, user.ID); err != nil {
92
+		h.d.Logger.ErrorContext(r.Context(), "password: bump epoch", "error", err)
93
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
94
+		return
95
+	}
96
+	if err := tx.Commit(r.Context()); err != nil {
97
+		h.d.Logger.ErrorContext(r.Context(), "password: commit", "error", err)
98
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
99
+		return
100
+	}
101
+
102
+	if err := h.d.Audit.Record(r.Context(), h.d.Pool, user.ID,
103
+		audit.ActionPasswordChanged, audit.TargetUser, user.ID, map[string]any{
104
+			"via": "settings",
105
+		}); err != nil {
106
+		h.d.Logger.WarnContext(r.Context(), "password: audit", "error", err)
107
+	}
108
+
109
+	h.renderPasswordForm(w, r, "", "Password updated. Other sessions have been signed out.")
110
+}
111
+
112
+func (h *Handlers) renderPasswordForm(w http.ResponseWriter, r *http.Request, errMsg, successMsg string) {
113
+	h.renderPage(w, r, "settings/password", map[string]any{
114
+		"Title":          "Password",
115
+		"CSRFToken":      middleware.CSRFTokenForRequest(r),
116
+		"SettingsActive": "password",
117
+		"RecentAuthOK":   h.recentAuthOK(r),
118
+		"Error":          errMsg,
119
+		"Success":        successMsg,
120
+	})
121
+}
122
+
123
+// validateNewPassword enforces signup parity for the chosen new password.
124
+func validateNewPassword(next, confirm string) string {
125
+	if len(next) < 10 {
126
+		return "Password must be at least 10 characters."
127
+	}
128
+	if next != confirm {
129
+		return "New password and confirmation don't match."
130
+	}
131
+	if passwords.IsCommon(next) {
132
+		return "That password is too common. Please choose another."
133
+	}
134
+	return ""
135
+}
internal/web/handlers/auth/password_change_test.goadded
@@ -0,0 +1,152 @@
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
+const (
14
+	//nolint:gosec // G101 false positive: test fixture, not a real credential.
15
+	pwOriginal = "correct horse battery staple"
16
+	//nolint:gosec // G101 false positive: test fixture, not a real credential.
17
+	pwNew = "tr0ub4dor & 3 horseshoes"
18
+)
19
+
20
+func loginPasswordUser(t *testing.T, name string) *client {
21
+	t.Helper()
22
+	httpsrv, captor := newTestServer(t, false)
23
+	cli := newClient(t, httpsrv)
24
+	mustSignup(t, cli, name, name+"@example.com", pwOriginal)
25
+	tok := extractTokenFromMessage(t, captor.all()[0], "/verify-email")
26
+	_ = cli.get(t, "/verify-email/"+tok).Body.Close()
27
+
28
+	csrf := cli.extractCSRF(t, "/login")
29
+	resp := cli.post(t, "/login", url.Values{
30
+		"csrf_token": {csrf},
31
+		"username":   {name},
32
+		"password":   {pwOriginal},
33
+	})
34
+	if resp.StatusCode != http.StatusSeeOther {
35
+		t.Fatalf("login: %d", resp.StatusCode)
36
+	}
37
+	_ = resp.Body.Close()
38
+	return cli
39
+}
40
+
41
+func TestPasswordChange_Roundtrip(t *testing.T) {
42
+	t.Parallel()
43
+	cli := loginPasswordUser(t, "pwa")
44
+	csrf := cli.extractCSRF(t, "/settings/password")
45
+	resp := cli.post(t, "/settings/password", url.Values{
46
+		"csrf_token":       {csrf},
47
+		"current_password": {pwOriginal},
48
+		"new_password":     {pwNew},
49
+		"confirm_password": {pwNew},
50
+	})
51
+	defer func() { _ = resp.Body.Close() }()
52
+	body, _ := io.ReadAll(resp.Body)
53
+	if !strings.Contains(string(body), "Password updated") {
54
+		t.Fatalf("expected success, got: %s", body)
55
+	}
56
+}
57
+
58
+func TestPasswordChange_RejectsWrongCurrent(t *testing.T) {
59
+	t.Parallel()
60
+	cli := loginPasswordUser(t, "pwb")
61
+	csrf := cli.extractCSRF(t, "/settings/password")
62
+	resp := cli.post(t, "/settings/password", url.Values{
63
+		"csrf_token":       {csrf},
64
+		"current_password": {"not-the-right-password"},
65
+		"new_password":     {pwNew},
66
+		"confirm_password": {pwNew},
67
+	})
68
+	defer func() { _ = resp.Body.Close() }()
69
+	body, _ := io.ReadAll(resp.Body)
70
+	if !strings.Contains(string(body), "Current password is incorrect") {
71
+		t.Fatalf("expected incorrect-current error, got: %s", body)
72
+	}
73
+}
74
+
75
+func TestPasswordChange_RejectsMismatch(t *testing.T) {
76
+	t.Parallel()
77
+	cli := loginPasswordUser(t, "pwc")
78
+	csrf := cli.extractCSRF(t, "/settings/password")
79
+	resp := cli.post(t, "/settings/password", url.Values{
80
+		"csrf_token":       {csrf},
81
+		"current_password": {pwOriginal},
82
+		"new_password":     {pwNew},
83
+		"confirm_password": {pwNew + "X"},
84
+	})
85
+	defer func() { _ = resp.Body.Close() }()
86
+	body, _ := io.ReadAll(resp.Body)
87
+	// HTML-escaping turns "don't" into "don&#39;t", so we check for the
88
+	// stable substring "and confirmation".
89
+	if !strings.Contains(string(body), "and confirmation") {
90
+		t.Fatalf("expected confirmation mismatch error, got: %s", body)
91
+	}
92
+}
93
+
94
+func TestPasswordChange_RejectsTooShort(t *testing.T) {
95
+	t.Parallel()
96
+	cli := loginPasswordUser(t, "pwd")
97
+	csrf := cli.extractCSRF(t, "/settings/password")
98
+	resp := cli.post(t, "/settings/password", url.Values{
99
+		"csrf_token":       {csrf},
100
+		"current_password": {pwOriginal},
101
+		"new_password":     {"short"},
102
+		"confirm_password": {"short"},
103
+	})
104
+	defer func() { _ = resp.Body.Close() }()
105
+	body, _ := io.ReadAll(resp.Body)
106
+	if !strings.Contains(string(body), "at least 10") {
107
+		t.Fatalf("expected too-short error, got: %s", body)
108
+	}
109
+}
110
+
111
+func TestPasswordChange_RejectsCommon(t *testing.T) {
112
+	t.Parallel()
113
+	cli := loginPasswordUser(t, "pwe")
114
+	csrf := cli.extractCSRF(t, "/settings/password")
115
+	resp := cli.post(t, "/settings/password", url.Values{
116
+		"csrf_token":       {csrf},
117
+		"current_password": {pwOriginal},
118
+		"new_password":     {"qwertyuiop"}, // 10-char common-list entry
119
+		"confirm_password": {"qwertyuiop"},
120
+	})
121
+	defer func() { _ = resp.Body.Close() }()
122
+	body, _ := io.ReadAll(resp.Body)
123
+	if !strings.Contains(string(body), "too common") {
124
+		t.Fatalf("expected too-common error, got: %s", body)
125
+	}
126
+}
127
+
128
+func TestPasswordChange_OriginalNoLongerWorks(t *testing.T) {
129
+	t.Parallel()
130
+	cli := loginPasswordUser(t, "pwf")
131
+	csrf := cli.extractCSRF(t, "/settings/password")
132
+	resp := cli.post(t, "/settings/password", url.Values{
133
+		"csrf_token":       {csrf},
134
+		"current_password": {pwOriginal},
135
+		"new_password":     {pwNew},
136
+		"confirm_password": {pwNew},
137
+	})
138
+	_ = resp.Body.Close()
139
+
140
+	// New session, attempt login with the OLD password — must fail.
141
+	cli2 := newClient(t, cli.srv)
142
+	csrf = cli2.extractCSRF(t, "/login")
143
+	resp = cli2.post(t, "/login", url.Values{
144
+		"csrf_token": {csrf},
145
+		"username":   {"pwf"},
146
+		"password":   {pwOriginal},
147
+	})
148
+	defer func() { _ = resp.Body.Close() }()
149
+	if resp.StatusCode == http.StatusSeeOther {
150
+		t.Fatalf("expected old password to fail; login succeeded")
151
+	}
152
+}
internal/web/templates/settings/password.htmladded
@@ -0,0 +1,40 @@
1
+{{ define "page" -}}
2
+<div class="shithub-settings-page">
3
+  {{ template "settings-nav" . }}
4
+  <div class="shithub-settings-content">
5
+    <h1>Password</h1>
6
+
7
+    {{ with .Error }}<p class="shithub-flash shithub-flash-error" role="alert">{{ . }}</p>{{ end }}
8
+    {{ with .Success }}<p class="shithub-flash shithub-flash-notice" role="status">{{ . }}</p>{{ end }}
9
+
10
+    <section class="shithub-settings-section">
11
+      <h2>Change password</h2>
12
+      <p>Make it long, unique, and stored in a password manager. Picking a new password signs out all your other sessions.</p>
13
+
14
+      {{ if not .RecentAuthOK }}
15
+        <p class="shithub-flash shithub-flash-error" role="alert">
16
+          Confirm 2FA before changing your password. <a href="/login">Sign in again</a> with your authenticator code.
17
+        </p>
18
+      {{ end }}
19
+
20
+      <form method="POST" action="/settings/password" novalidate>
21
+        <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
22
+        <label>
23
+          <span>Current password</span>
24
+          <input type="password" name="current_password" required autocomplete="current-password">
25
+        </label>
26
+        <label>
27
+          <span>New password</span>
28
+          <input type="password" name="new_password" required minlength="10" autocomplete="new-password">
29
+          <small>At least 10 characters. Long passphrases beat short cryptic ones.</small>
30
+        </label>
31
+        <label>
32
+          <span>Confirm new password</span>
33
+          <input type="password" name="confirm_password" required minlength="10" autocomplete="new-password">
34
+        </label>
35
+        <button type="submit" class="shithub-button shithub-button-primary">Update password</button>
36
+      </form>
37
+    </section>
38
+  </div>
39
+</div>
40
+{{- end }}