Username change functionality

This commit is contained in:
Jonathon Leight
2026-06-22 10:54:18 -04:00
parent a07d2899c6
commit f40ca97393
14 changed files with 490 additions and 20 deletions
+36
View File
@@ -2,10 +2,12 @@ package auth
import (
"encoding/hex"
"errors"
"net/http"
"strconv"
"time"
"github.com/jleight/meshtender/internal/store"
"github.com/jleight/meshtender/internal/web"
)
@@ -51,11 +53,19 @@ func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) {
}
views = append(views, passkeyView{ID: c.ID, ShortID: short, Name: c.Name, Added: c.CreatedAt})
}
// When set, the user changed their username recently and must wait until
// this time before changing it again.
nextRename, err := s.Store.NextRenameAllowed(r.Context(), uid)
if err != nil {
http.Error(w, "could not load account", http.StatusInternalServerError)
return
}
s.Render(w, r, "account.html", map[string]any{
"User": u,
"DisplayName": u.DisplayName, // nil when unset
"HasPassword": u.PasswordHash != nil,
"Passkeys": views,
"NextRename": nextRename, // nil when a rename is allowed now
"Error": r.URL.Query().Get("error"),
"OK": r.URL.Query().Get("ok"),
"PKMsg": r.URL.Query().Get("pk"),
@@ -63,6 +73,32 @@ func (s *Handlers) pageAccount(w http.ResponseWriter, r *http.Request) {
})
}
// handleChangeUsername renames the current user, enforcing validation, the
// per-user rename interval, and the release cooldown on names others hold.
func (s *Handlers) handleChangeUsername(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.Auth.CurrentUserID(ctx)
newName := NormalizeUsername(r.FormValue("username"))
if !ValidUsername(newName) {
accountRedirect(w, r, "error", "Choose a username 332 characters long, using only letters, digits, and _ . -")
return
}
meta := store.UsernameChangeContext{ChangedBy: uid, IP: web.ClientIP(r), UserAgent: r.UserAgent()}
err := s.Store.SetUsername(ctx, uid, newName, meta, true)
switch {
case errors.Is(err, store.ErrDuplicate), errors.Is(err, store.ErrUsernameReserved):
// Collapse "taken" and "reserved" into one message so we don't reveal
// that a name was previously in use by someone else.
accountRedirect(w, r, "error", "That username isn't available. Please choose another.")
case errors.Is(err, store.ErrRenameTooSoon):
accountRedirect(w, r, "error", "You can only change your username once every 30 days.")
case err != nil:
accountRedirect(w, r, "error", "Could not change your username.")
default:
accountRedirect(w, r, "ok", "Username changed to @"+newName+".")
}
}
// handleUpdateProfile saves the user's display name.
func (s *Handlers) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
uid := s.Auth.CurrentUserID(r.Context())
+28 -9
View File
@@ -12,25 +12,44 @@
{{if .OK}}<div class="alert alert-success">{{.OK}}</div>{{end}}
<div class="card">
<div class="card-header"><h3 class="card-title">Profile</h3></div>
<div class="card-header"><h3 class="card-title">Username</h3></div>
<div class="card-body">
<p class="text-secondary">Your username can't be changed. Your display name is shown to people you share with.</p>
<form method="post" action="/account/profile">
<div class="row g-3">
<p class="text-secondary">Your username is your unique handle (332 chars: letters, digits, <code>_ . -</code>). You can change it once every 30 days. When you do, your old username is reserved for 90 days before anyone else can take it.</p>
<form method="post" action="/account/username">
<div class="row g-3 align-items-end">
<div class="col-md">
<label class="form-label" for="acct_username">Username</label>
<input type="text" class="form-control" id="acct_username" value="{{.User.Username}}" disabled>
<div class="input-group input-group-flat">
<span class="input-group-text">@</span>
<input type="text" class="form-control" id="acct_username" name="username" minlength="3" maxlength="32" pattern="[a-zA-Z0-9_.\-]+" value="{{.User.Username}}" {{if .NextRename}}disabled{{end}}>
</div>
</div>
<div class="col-md-auto">
<button type="submit" class="btn btn-primary" {{if .NextRename}}disabled{{end}}>Change username</button>
</div>
</div>
{{if .NextRename}}
<small class="form-hint mt-2 d-block">You changed your username recently. You can change it again on {{.NextRename.Format "Jan 2, 2006"}}.</small>
{{end}}
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Display name</h3></div>
<div class="card-body">
<p class="text-secondary">Your display name is what other people see — on shares and in organizations. It can be anything, and it isn't tied to sign-in.</p>
<form method="post" action="/account/profile">
<div class="row g-3 align-items-end">
<div class="col-md">
<label class="form-label" for="acct_display_name">Display name <span class="form-label-description">optional</span></label>
<input type="text" class="form-control" id="acct_display_name" name="display_name" maxlength="64" value="{{if .DisplayName}}{{.DisplayName}}{{end}}" placeholder="Shown to people you share with">
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">Save profile</button>
<div class="col-md-auto">
<button type="submit" class="btn btn-primary">Save display name</button>
</div>
</div>
</form>
</div>
</div>
+1
View File
@@ -50,6 +50,7 @@ func (s *Handlers) Routes() chi.Router {
r.Group(func(r chi.Router) {
r.Use(s.Auth.RequireSSO)
r.Get("/account", s.pageAccount)
r.Post("/account/username", s.handleChangeUsername)
r.Post("/account/profile", s.handleUpdateProfile)
r.Post("/account/password", s.handleChangePassword)
r.Post("/account/passkeys/rename", s.handleRenamePasskey)
+27
View File
@@ -155,3 +155,30 @@ func (s *Handlers) handleSetUserCaps(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
// usernameHistoryLimit bounds the rename history shown on the admin page.
const usernameHistoryLimit = 100
// pageUserHistory shows a user's username-change history. Admin-only (mounted
// under the manage-users capability): old usernames are never exposed elsewhere.
func (s *Handlers) pageUserHistory(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
u, err := s.Store.GetUserByID(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
changes, err := s.Store.ListUsernameChanges(r.Context(), id, usernameHistoryLimit)
if err != nil {
http.Error(w, "could not load history", http.StatusInternalServerError)
return
}
s.Render(w, r, "admin_user_history.html", map[string]any{
"Account": u,
"Changes": changes,
})
}
@@ -0,0 +1,43 @@
{{define "title"}}Username history · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Username history</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
Rename history for <strong>{{.Account.Name}}</strong> <span class="text-secondary">@{{.Account.Username}}</span>
(user #{{.Account.ID}}). This record is visible to user managers only — old usernames are never shown
on public or member-facing pages.
</p>
{{if .Changes}}
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr><th>When</th><th>Change</th><th>By</th><th>IP</th></tr>
</thead>
<tbody>
{{range .Changes}}
<tr>
<td class="text-nowrap">{{.ChangedAt.Format "Jan 2, 2006 15:04"}}</td>
<td><span class="text-secondary">@{{.OldUsername}}</span><span class="font-monospace">@{{.NewUsername}}</span></td>
<td>{{if .BySelf}}self{{else if .ByActor}}admin (@{{.ByActor}}){{else}}admin{{end}}</td>
<td class="font-monospace text-secondary">{{if .IP}}{{.IP}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-secondary">This user has never changed their username.</p>
{{end}}
</div>
</div>
<a class="back-link mt-3" href="/admin/users">{{template "icon-arrow-left" "me-1"}}Users</a>
{{end}}
+1
View File
@@ -29,6 +29,7 @@
<input class="form-check-input" type="checkbox" name="manage_catalog" {{if .CapManageCatalog}}checked{{end}}>
<span class="form-check-label">manage catalog</span>
</label>
<a href="/admin/users/{{.ID}}/history" class="btn btn-sm btn-ghost-secondary">History</a>
<button type="submit" class="btn btn-sm">Save</button>
</form>
{{end}}
+1
View File
@@ -165,6 +165,7 @@ func (s *Handlers) appRouter() chi.Router {
r.With(s.requireCap(capCatalog)).Get("/catalog", s.pageCatalog)
r.With(s.requireCap(capCatalog)).Post("/catalog/{id}", s.handleUpdateCommand)
r.With(s.requireCap(capUsers)).Get("/users", s.pageUsers)
r.With(s.requireCap(capUsers)).Get("/users/{id}/history", s.pageUserHistory)
r.With(s.requireCap(capUsers)).Post("/users/{id}", s.handleSetUserCaps)
})
})
+6 -9
View File
@@ -14,7 +14,7 @@ type CommandLogEntry struct {
ID int64
RepeaterID int64
UserID *int64
SenderName string // username (or display name); "(deleted)" if user gone
SenderName string // username snapshotted at send time; "(deleted)" if unknown
CommandText string
SentAt time.Time
AckReceived bool
@@ -26,8 +26,8 @@ type CommandLogEntry struct {
func (s *Store) LogCommand(ctx context.Context, repeaterID, userID, sessionID, commandID int64, text string) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO command_log (repeater_id, user_id, session_id, command_id, command_text)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
INSERT INTO command_log (repeater_id, user_id, session_id, command_id, command_text, sender_username)
VALUES ($1, $2, $3, $4, $5, (SELECT username FROM users WHERE id = $2)) RETURNING id`,
repeaterID, userID, sessionID, nullID(commandID), text).Scan(&id)
if err != nil {
return 0, fmt.Errorf("log command: %w", err)
@@ -95,9 +95,8 @@ func (s *Store) ListCommandLogSessionsPage(ctx context.Context, repeaterID int64
// behavior of the previous query).
headers, err := s.pool.Query(ctx, `
SELECT cs.id, cs.started_at, cs.ended_at,
COALESCE(NULLIF(su.display_name, ''), su.username, '(deleted)')
COALESCE(cs.sender_username, '(deleted)')
FROM console_sessions cs
LEFT JOIN users su ON su.id = cs.user_id
WHERE cs.repeater_id = $1
AND ($2 OR (cs.started_at, cs.id) < ($3, $4))
AND EXISTS (SELECT 1 FROM command_log l WHERE l.session_id = cs.id)
@@ -172,11 +171,10 @@ type OwnerCommandLogEntry struct {
func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, limit int) ([]OwnerCommandLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT l.repeater_id, r.public_id, r.name,
COALESCE(NULLIF(u.display_name, ''), u.username, '(deleted)'),
COALESCE(l.sender_username, '(deleted)'),
l.command_text, l.sent_at, l.ack_received, l.response_text
FROM command_log l
JOIN repeaters r ON r.id = l.repeater_id
LEFT JOIN users u ON u.id = l.user_id
WHERE r.owner_id = $1
ORDER BY l.sent_at DESC
LIMIT $2`, ownerID, limit)
@@ -195,10 +193,9 @@ func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, l
func (s *Store) ListCommandLog(ctx context.Context, repeaterID int64, limit int) ([]*CommandLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT l.id, l.repeater_id, l.user_id,
COALESCE(NULLIF(u.display_name, ''), u.username, '(deleted)'),
COALESCE(l.sender_username, '(deleted)'),
l.command_text, l.sent_at, l.ack_received, l.response_text
FROM command_log l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.repeater_id = $1
ORDER BY l.sent_at DESC
LIMIT $2`, repeaterID, limit)
+3 -1
View File
@@ -8,8 +8,10 @@ import (
// StartConsoleSession records the start of a console session and returns its id.
func (s *Store) StartConsoleSession(ctx context.Context, repeaterID, userID int64) (int64, error) {
var id int64
// Snapshot the username so the log stays a point-in-time record after renames.
err := s.pool.QueryRow(ctx,
`INSERT INTO console_sessions (repeater_id, user_id) VALUES ($1, $2) RETURNING id`,
`INSERT INTO console_sessions (repeater_id, user_id, sender_username)
VALUES ($1, $2, (SELECT username FROM users WHERE id = $2)) RETURNING id`,
repeaterID, userID).Scan(&id)
if err != nil {
return 0, fmt.Errorf("start console session: %w", err)
@@ -0,0 +1,39 @@
-- +goose Up
-- Usernames become user-changeable. The stable identity is still users.id; the
-- username is a renamable, unique, human-checkable handle. This migration adds
-- the machinery to keep that safe:
--
-- 1. username_changes is an admin/security-only audit trail. It maps a
-- historical handle back to a user (forensics after a rename), backs the
-- release cooldown (a freed name is reserved for a window), and records who
-- made each change. Old usernames live here and are never shown publicly.
-- 2. sender_username on command_log / console_sessions snapshots the actor's
-- username at write time, so the command log is an immutable, point-in-time
-- record. It no longer depends on the live (renamable, and free-form,
-- spoofable) display name and it survives account deletion.
CREATE TABLE username_changes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
changed_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
ip TEXT,
user_agent TEXT
);
-- Forensics: a single user's rename timeline.
CREATE INDEX username_changes_user_id_idx ON username_changes(user_id, changed_at DESC);
-- Cooldown lookup: was this name released recently, and by whom?
CREATE INDEX username_changes_old_username_idx ON username_changes(lower(old_username), changed_at DESC);
ALTER TABLE command_log ADD COLUMN sender_username TEXT;
ALTER TABLE console_sessions ADD COLUMN sender_username TEXT;
-- Backfill existing rows from the current username (the best point-in-time
-- value available retroactively).
UPDATE command_log l SET sender_username = u.username FROM users u WHERE u.id = l.user_id;
UPDATE console_sessions cs SET sender_username = u.username FROM users u WHERE u.id = cs.user_id;
-- +goose Down
ALTER TABLE console_sessions DROP COLUMN sender_username;
ALTER TABLE command_log DROP COLUMN sender_username;
DROP TABLE username_changes;
+177
View File
@@ -0,0 +1,177 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// Usernames are user-changeable, but with two guards that make a freed handle
// safe to leave behind:
//
// - UsernameReleaseCooldown: once a username is given up, nobody else may
// claim it for this window — only its previous owner can take it back. This
// blunts the impersonation/squatting attack where a renamed-away handle is
// grabbed to inherit its history.
// - UsernameRenameInterval: a user may only rename themselves this often.
// Admin-initiated changes bypass it.
const (
UsernameReleaseCooldown = 90 * 24 * time.Hour
UsernameRenameInterval = 30 * 24 * time.Hour
)
// ErrUsernameReserved is returned when a username was recently released by
// someone else and is still within its cooldown. It is deliberately distinct
// from ErrDuplicate internally, but callers should surface both as a generic
// "unavailable" so they don't reveal that a name was previously in use.
var ErrUsernameReserved = errors.New("store: username reserved")
// ErrRenameTooSoon is returned when a self-service rename is attempted before
// the per-user rename interval has elapsed.
var ErrRenameTooSoon = errors.New("store: rename too soon")
// UsernameChangeContext is the audit metadata recorded alongside a rename.
type UsernameChangeContext struct {
ChangedBy int64 // the acting user: the account itself, or an admin
IP string // client IP, "" if unknown
UserAgent string // client UA, "" if unknown
}
// nameReservedByOther reports whether candidate was released within the cooldown
// by some user other than exceptUserID (pass 0 to match every prior owner, e.g.
// at signup where there is no incumbent). The previous owner is always allowed
// to reclaim their own freed name. q is the pool or an open transaction.
func nameReservedByOther(ctx context.Context, q rowQuerier, candidate string, exceptUserID int64) (bool, error) {
cutoff := time.Now().Add(-UsernameReleaseCooldown)
var reserved bool
err := q.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM username_changes
WHERE lower(old_username) = $1
AND changed_at > $2
AND user_id IS DISTINCT FROM $3
)`, candidate, cutoff, exceptUserID).Scan(&reserved)
if err != nil {
return false, fmt.Errorf("check reserved: %w", err)
}
return reserved, nil
}
// SetUsername renames userID to newUsername, recording the change in the audit
// trail. All checks and the update run in one transaction; the UNIQUE
// constraint is the final race guard.
//
// enforceInterval gates the per-user rename rate limit: pass true for
// self-service changes, false for admin-initiated ones (which still respect
// uniqueness and the release cooldown on names others hold). Returns nil with no
// change when newUsername already matches the current one.
func (s *Store) SetUsername(ctx context.Context, userID int64, newUsername string, meta UsernameChangeContext, enforceInterval bool) error {
return s.inTx(ctx, func(tx pgx.Tx) error {
var old string
if err := tx.QueryRow(ctx, `SELECT username FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&old); err != nil {
return notFoundOr(err, "get user")
}
if old == newUsername {
return nil // no-op
}
if enforceInterval {
var last *time.Time
if err := tx.QueryRow(ctx,
`SELECT max(changed_at) FROM username_changes WHERE user_id = $1 AND changed_by = $1`,
userID).Scan(&last); err != nil {
return fmt.Errorf("rate check: %w", err)
}
if last != nil && last.After(time.Now().Add(-UsernameRenameInterval)) {
return ErrRenameTooSoon
}
}
reserved, err := nameReservedByOther(ctx, tx, newUsername, userID)
if err != nil {
return err
}
if reserved {
return ErrUsernameReserved
}
if _, err := tx.Exec(ctx, `UPDATE users SET username = $1 WHERE id = $2`, newUsername, userID); err != nil {
if isUniqueViolation(err) {
return ErrDuplicate
}
return fmt.Errorf("update username: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO username_changes (user_id, old_username, new_username, changed_by, ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)`,
userID, old, newUsername, meta.ChangedBy, nullStr(meta.IP), nullStr(meta.UserAgent)); err != nil {
return fmt.Errorf("record username change: %w", err)
}
return nil
})
}
// NextRenameAllowed returns the earliest time userID may change their own
// username again, or nil if they may change it now (no prior self-service change
// within the interval).
func (s *Store) NextRenameAllowed(ctx context.Context, userID int64) (*time.Time, error) {
var last *time.Time
if err := s.pool.QueryRow(ctx,
`SELECT max(changed_at) FROM username_changes WHERE user_id = $1 AND changed_by = $1`,
userID).Scan(&last); err != nil {
return nil, fmt.Errorf("next rename allowed: %w", err)
}
if last == nil {
return nil, nil
}
next := last.Add(UsernameRenameInterval)
if next.After(time.Now()) {
return &next, nil
}
return nil, nil
}
// UsernameChange is one row of a user's rename history, for the admin view.
type UsernameChange struct {
OldUsername string
NewUsername string
ChangedAt time.Time
BySelf bool // changed_by == user_id
ByActor *string // acting username when admin-initiated and still known
IP *string
UserAgent *string
}
// ListUsernameChanges returns a user's rename history, newest first. Admin-only:
// old usernames are never exposed on public or member-facing surfaces.
func (s *Store) ListUsernameChanges(ctx context.Context, userID int64, limit int) ([]UsernameChange, error) {
rows, err := s.pool.Query(ctx, `
SELECT c.old_username, c.new_username, c.changed_at,
(c.changed_by IS NOT DISTINCT FROM c.user_id) AS by_self,
a.username, c.ip, c.user_agent
FROM username_changes c
LEFT JOIN users a ON a.id = c.changed_by
WHERE c.user_id = $1
ORDER BY c.changed_at DESC, c.id DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list username changes: %w", err)
}
return collectRows(rows, func(r pgx.Row) (UsernameChange, error) {
var c UsernameChange
err := r.Scan(&c.OldUsername, &c.NewUsername, &c.ChangedAt, &c.BySelf, &c.ByActor, &c.IP, &c.UserAgent)
return c, err
})
}
// nullStr returns nil for an empty string so it stores as SQL NULL.
func nullStr(s string) *string {
if s == "" {
return nil
}
return &s
}
+112
View File
@@ -0,0 +1,112 @@
package store
import (
"errors"
"testing"
)
// selfChange is the metadata for a user renaming themselves.
func selfChange(uid int64) UsernameChangeContext {
return UsernameChangeContext{ChangedBy: uid, IP: "203.0.113.7", UserAgent: "test-agent"}
}
func TestSetUsername(t *testing.T) {
st, ctx := orgTestStore(t)
mk := func(name string) int64 {
u, err := st.CreateUser(ctx, name, "")
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
return u.ID
}
t.Run("rename records audit and updates user", func(t *testing.T) {
uid := mk("alice")
if err := st.SetUsername(ctx, uid, "alice2", selfChange(uid), true); err != nil {
t.Fatalf("rename: %v", err)
}
u, err := st.GetUserByID(ctx, uid)
if err != nil || u.Username != "alice2" {
t.Fatalf("got %q (%v), want alice2", u.Username, err)
}
hist, err := st.ListUsernameChanges(ctx, uid, 10)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(hist) != 1 {
t.Fatalf("got %d history rows, want 1", len(hist))
}
h := hist[0]
if h.OldUsername != "alice" || h.NewUsername != "alice2" || !h.BySelf {
t.Fatalf("bad history row: %+v", h)
}
if h.IP == nil || *h.IP != "203.0.113.7" {
t.Fatalf("ip not recorded: %+v", h.IP)
}
})
t.Run("no-op rename writes no history", func(t *testing.T) {
uid := mk("samename")
if err := st.SetUsername(ctx, uid, "samename", selfChange(uid), true); err != nil {
t.Fatalf("no-op: %v", err)
}
hist, _ := st.ListUsernameChanges(ctx, uid, 10)
if len(hist) != 0 {
t.Fatalf("no-op wrote %d history rows", len(hist))
}
})
t.Run("duplicate of active user is rejected", func(t *testing.T) {
a := mk("dupa")
mk("dupb")
if err := st.SetUsername(ctx, a, "dupb", selfChange(a), true); !errors.Is(err, ErrDuplicate) {
t.Fatalf("got %v, want ErrDuplicate", err)
}
})
t.Run("rate limit blocks a second self-service rename", func(t *testing.T) {
uid := mk("rl1")
if err := st.SetUsername(ctx, uid, "rl2", selfChange(uid), true); err != nil {
t.Fatalf("first rename: %v", err)
}
if err := st.SetUsername(ctx, uid, "rl3", selfChange(uid), true); !errors.Is(err, ErrRenameTooSoon) {
t.Fatalf("got %v, want ErrRenameTooSoon", err)
}
// An admin-initiated change (enforceInterval=false) bypasses the limit.
admin := mk("rladmin")
if err := st.SetUsername(ctx, uid, "rl3", UsernameChangeContext{ChangedBy: admin}, false); err != nil {
t.Fatalf("admin bypass: %v", err)
}
// NextRenameAllowed reflects the user's own recent change.
next, err := st.NextRenameAllowed(ctx, uid)
if err != nil {
t.Fatalf("next: %v", err)
}
if next == nil {
t.Fatalf("expected a cooldown deadline, got nil")
}
})
t.Run("released name is reserved from others but reclaimable by owner", func(t *testing.T) {
owner := mk("shared")
other := mk("other")
// Owner releases "shared".
if err := st.SetUsername(ctx, owner, "shared-new", selfChange(owner), true); err != nil {
t.Fatalf("release: %v", err)
}
// Someone else can't take it during the cooldown.
if err := st.SetUsername(ctx, other, "shared", selfChange(other), true); !errors.Is(err, ErrUsernameReserved) {
t.Fatalf("got %v, want ErrUsernameReserved", err)
}
// Signup can't grab it either.
if _, err := st.CreateUser(ctx, "shared", ""); !errors.Is(err, ErrUsernameReserved) {
t.Fatalf("signup got %v, want ErrUsernameReserved", err)
}
// The original owner may reclaim it (rate limit aside, tested via the
// admin path to isolate the cooldown rule).
if err := st.SetUsername(ctx, owner, "shared", UsernameChangeContext{ChangedBy: owner}, false); err != nil {
t.Fatalf("owner reclaim: %v", err)
}
})
}
+12 -1
View File
@@ -49,12 +49,23 @@ func scanUser(row pgx.Row) (*User, error) {
}
// CreateUser inserts a new user and returns it. displayName may be empty (then
// stored as NULL). Returns ErrDuplicate if the username is already taken.
// stored as NULL). Returns ErrDuplicate if the username is already taken, or
// ErrUsernameReserved if it was recently released by someone else and is still
// within its cooldown.
func (s *Store) CreateUser(ctx context.Context, username, displayName string) (*User, error) {
var dn *string
if displayName != "" {
dn = &displayName
}
// A brand-new account has no incumbent identity, so any prior owner's recent
// release reserves the name (exceptUserID 0 matches no one).
reserved, err := nameReservedByOther(ctx, s.pool, username, 0)
if err != nil {
return nil, err
}
if reserved {
return nil, ErrUsernameReserved
}
u, err := scanUser(s.pool.QueryRow(ctx,
`INSERT INTO users (username, display_name) VALUES ($1, $2) RETURNING `+userCols,
username, dn))
+4
View File
@@ -94,3 +94,7 @@ func clientIP(r *http.Request) string {
}
return r.RemoteAddr
}
// ClientIP exposes clientIP for handlers that record the caller's address
// (e.g. the username-change audit trail).
func ClientIP(r *http.Request) string { return clientIP(r) }