mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Username change functionality
This commit is contained in:
@@ -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 3–32 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())
|
||||
|
||||
@@ -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 (3–32 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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user