This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrProfileInput = errors.New("auth: invalid profile change")
|
||||
ErrProfileAccess = errors.New("auth: profile session is unavailable")
|
||||
ErrProfileConflict = errors.New("auth: profile changed; reload before editing")
|
||||
ErrUsernameUnavailable = errors.New("auth: username is unavailable")
|
||||
)
|
||||
|
||||
// OwnProfile contains mutable identity, not credentials or organization roles.
|
||||
// Revision is independent of timestamps and increases for every profile edit.
|
||||
type OwnProfile struct {
|
||||
UserID, Username, Email, DisplayName string
|
||||
Revision int64
|
||||
}
|
||||
|
||||
// ProfileEdit is a trusted repository command, not an HTTP input model. The
|
||||
// application must authenticate the session, validate CSRF/origin and rate-limit
|
||||
// mutations. Username edits additionally require recent reauthentication (and
|
||||
// any account-specific MFA). For password reauthentication, supply the verified
|
||||
// hash so a concurrent password reset invalidates the write. After verified
|
||||
// passkey approval, leave it empty. Do not log or serialize this command.
|
||||
type ProfileEdit struct {
|
||||
UserID string
|
||||
SessionDigest [32]byte
|
||||
ExpectedRevision int64
|
||||
Field, Value string
|
||||
ExpectedPasswordHash string
|
||||
}
|
||||
|
||||
// NormalizeProfileValue validates only supported fields. Email is deliberately
|
||||
// absent: verified mailbox changes need a separate pending/confirmation flow.
|
||||
func NormalizeProfileValue(field, value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch field {
|
||||
case "username":
|
||||
if !identifierPattern.MatchString(value) {
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
case "display_name":
|
||||
if value == "" || len(value) > 128 || !utf8.ValidString(value) {
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) {
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
}
|
||||
default:
|
||||
return "", ErrProfileInput
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// OwnProfileRepository is optional; no change to the authentication Repository
|
||||
// interface is required. It derives access from the current session, never from
|
||||
// a site-wide administrator flag. Implementations atomically recheck identity,
|
||||
// session and revision, mutate one field, and append the audit. Username edits
|
||||
// revoke other sessions but preserve the acting session. They never reassign
|
||||
// stable IDs, memberships, passkeys, billing identities or historical records.
|
||||
type OwnProfileRepository interface {
|
||||
OwnProfile(context.Context, [32]byte, time.Time) (OwnProfile, error)
|
||||
UpdateOwnProfile(context.Context, ProfileEdit, AuditEvent) (OwnProfile, error)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeProfileValue(t *testing.T) {
|
||||
for _, value := range []struct{ field, value, want string }{
|
||||
{"username", " Reader.One ", "Reader.One"}, {"display_name", " Émilie ★ ", "Émilie ★"},
|
||||
} {
|
||||
got, err := NormalizeProfileValue(value.field, value.value)
|
||||
if err != nil || got != value.want {
|
||||
t.Fatalf("normalization: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []struct{ field, value string }{
|
||||
{"email", "new@example.test"}, {"role", "owner"}, {"username", "a"}, {"username", "foo@bar"},
|
||||
{"display_name", ""}, {"display_name", "hello\x00world"}, {"display_name", "hello\nworld"}, {"display_name", string([]byte{0xff})},
|
||||
} {
|
||||
if _, err := NormalizeProfileValue(value.field, value.value); err == nil {
|
||||
t.Fatalf("invalid field accepted: %s", value.field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzProfileValue(f *testing.F) {
|
||||
f.Add("username", "reader.one")
|
||||
f.Add("display_name", "Émilie")
|
||||
f.Add("email", "a@example.test")
|
||||
f.Fuzz(func(t *testing.T, field, value string) {
|
||||
normal, err := NormalizeProfileValue(field, value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(normal) == 0 || len(normal) > 128 {
|
||||
t.Fatal("unbounded value")
|
||||
}
|
||||
again, err := NormalizeProfileValue(field, normal)
|
||||
if err != nil || again != normal {
|
||||
t.Fatal("unstable normalization")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user