Let admins reset a locked-out account

Passkeys now require user verification, so a user who loses their only
passkey has no way back in. The admin can now set a new password for an
account. The reset also removes all passkeys and sessions of the user,
so an attacker who holds one of them is locked out too.

The reset needs a recent admin login, like the other credential
changes. The admin account itself is changed under Password instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commit9a8ec1a4f034950da504bd522accfd7f6ec6ee5e
Parent2a917e7
6 files changed, 127 insertions(+), 1 deletion(-)
▾MREADME.md
@@ -13,7 +13,7 @@ The frontend works without JavaScript — JS is only required for WebAuthn, with
- **Templates** — issue and patch templates per repository
- **Releases** — create releases with source archives (zip/tar.gz), uploaded assets, and optional tag creation
- **SSH push/pull** — built-in SSH server, no external git daemon needed
- **Auth** — password login or passkeys (WebAuthn/FIDO2)
- **Auth** — password login or passkeys (WebAuthn/FIDO2); admins can reset a locked-out account
- **Commit signing** — patches merged and files edited through the UI are automatically signed; verification badges shown in the commit log
- **Registration control** — open registration, disabled, or queue mode where the admin manually approves new accounts
▾Minternal/db/users.go
@@ -110,6 +110,25 @@ func (d *DB) SetPasswordHash(ctx context.Context, id int64, hash *string) error
return err
}
// ResetCredentials sets a new password and removes every passkey and
// session of the user.
func (d *DB) ResetCredentials(ctx context.Context, id int64, hash string) error {
tx, err := d.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id); err != nil {
return err
}
for _, table := range []string{"passkeys", "sessions"} {
if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE user_id = ?`, id); err != nil {
return err
}
}
return tx.Commit()
}
// ClearPasswordHash removes the password only while a passkey is left. The
// check is part of the statement, so two concurrent removals cannot race an
// account into having no login method at all. It reports false when it did
▾Minternal/web/e2e/settings_test.go
@@ -1,6 +1,7 @@
package e2e
import (
"context"
"crypto/ed25519"
"crypto/rand"
"net/url"
@@ -8,6 +9,8 @@ import (
"testing"
gossh "golang.org/x/crypto/ssh"
"hearthforge/internal/db"
)
// settingsPubKey returns a throwaway ed25519 public key in authorized_keys
@@ -178,6 +181,40 @@ func TestSettings(t *testing.T) {
}
})
t.Run("admin reset replaces password and drops passkeys and sessions", func(t *testing.T) {
dora := e.register("dora", "dorapass1")
u, err := e.DB.UserByName(context.Background(), "dora")
if err != nil || u == nil {
t.Fatalf("user dora: %v", err)
}
if err := e.DB.CreatePasskey(context.Background(), u.ID, "cred-dora", "pk", 0, db.NowISO()); err != nil {
t.Fatal(err)
}
alice.post("/admin/users/reset", url.Values{
"username": {"dora"}, "password": {"newpass123"},
}).mustStatus(403)
r := admin.post("/admin/users/reset", url.Values{
"username": {"dora"}, "password": {"newpass123"},
})
if loc := r.mustRedirect("/settings"); !strings.Contains(loc, "success=user_reset") {
t.Errorf("location = %q", loc)
}
dora.get("/settings").mustRedirect("/login")
if keys, _ := e.DB.ListPasskeys(context.Background(), u.ID); len(keys) != 0 {
t.Errorf("passkeys left = %d", len(keys))
}
e.login("dora", "newpass123")
})
t.Run("admin reset rejects the admin account", func(t *testing.T) {
r := admin.post("/admin/users/reset", url.Values{
"username": {"admin"}, "password": {"newpass123"},
})
if loc := settingsLocation(t, r); !strings.Contains(loc, "error") {
t.Errorf("location = %q", loc)
}
})
t.Run("admin cannot delete the admin account", func(t *testing.T) {
r := admin.post("/admin/users/delete", url.Values{"username": {"admin"}})
r.mustStatus(302)
▾Minternal/web/session_test.go
@@ -66,6 +66,10 @@ func TestStaleSessionMustSignInAgain(t *testing.T) {
if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" {
t.Fatalf("revoke passkey Location = %q", loc)
}
rec = postForm(t, h, "/admin/users/reset", url.Values{"username": {"bob"}, "password": {"newpass123"}}, cookie)
if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" {
t.Fatalf("admin reset Location = %q", loc)
}
rec = postForm(t, h, "/auth/passkey/register/options", nil, cookie)
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), `"reauth"`) {
t.Fatalf("passkey options: %d %s", rec.Code, rec.Body.String())
▾Minternal/web/settings.go
@@ -60,6 +60,7 @@ func (s *Server) settingsRoutes(r chi.Router) {
r.Use(s.requireAdmin)
r.Post("/admin/users", s.adminCreateUser)
r.Post("/admin/users/delete", s.adminDeleteUser)
r.Post("/admin/users/reset", s.adminResetUser)
r.Post("/admin/users/approve", s.adminApproveUser)
r.Post("/admin/users/deny", s.adminDenyUser)
r.Post("/admin/users/approve-all", s.adminApproveAll)
@@ -441,6 +442,52 @@ func (s *Server) adminDeleteUser(w http.ResponseWriter, r *http.Request) {
settingsSuccess(w, r, "user_deleted")
}
// adminResetUser gives a locked-out user a password chosen by the admin and
// removes their passkeys and sessions.
func (s *Server) adminResetUser(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
if len(username) > s.Cfg.MaxUsernameBytes || len(password) > s.Cfg.MaxPasswordBytes {
http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
return
}
if username == db.AdminUsername {
settingsError(w, r, "Change the admin password under Password")
return
}
if !recentLogin(User(r)) {
redirectTo(w, r, reauthURL("/settings"))
return
}
if len(password) < minPasswordLength {
settingsError(w, r, "Password must be at least 8 characters")
return
}
target, err := s.DB.UserByName(r.Context(), username)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if target == nil {
settingsError(w, r, "User not found")
return
}
hash, err := db.HashPassword(password)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
if err := s.DB.ResetCredentials(r.Context(), target.ID, hash); err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
settingsSuccess(w, r, "user_reset")
}
func (s *Server) adminApproveUser(w http.ResponseWriter, r *http.Request) {
id, err := formID(r, "id")
if err != nil {
▾Minternal/web/views/settings.go
@@ -19,6 +19,7 @@ var successMessages = map[string]string{
"theme": "Theme preference saved.",
"user_created": "Account created.",
"user_deleted": "Account deleted.",
"user_reset": "Account reset. Passkeys and sessions were removed.",
"user_approved": "Account approved.",
"user_denied": "Account denied.",
"all_approved": "All pending accounts approved.",
@@ -343,6 +344,24 @@ func userManagement() g.Node {
Button(Class("btn btn-primary"), Type("submit"), g.Text("Create account")),
),
),
H3(Style("margin-top: var(--space-6)"), g.Text("Reset account")),
P(Class("text-muted"), g.Text("Sets a new password and removes all passkeys and sessions of the user.")),
Form(Method("POST"), Action("/admin/users/reset"), Class("settings-form"),
Style("margin-top: var(--space-4)"),
Div(Class("form-group"),
Label(Class("form-label"), For("reset_username"), g.Text("Username")),
Input(Class("form-input"), Type("text"), ID("reset_username"), Name("username"),
AutoComplete("off")),
),
Div(Class("form-group"),
Label(Class("form-label"), For("reset_password"), g.Text("New password")),
Input(Class("form-input"), Type("password"), ID("reset_password"),
Name("password"), AutoComplete("new-password")),
),
Div(Class("form-actions"),
Button(Class("btn btn-danger"), Type("submit"), g.Text("Reset account")),
),
),
H3(Style("margin-top: var(--space-6)"), g.Text("Delete account")),
Form(Method("POST"), Action("/admin/users/delete"), Class("settings-form"),
Style("margin-top: var(--space-4)"),