Phase 1: accounts and sessions
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Implements PLAN.md Phase 1. A client can now register, log in, manage its
devices and set its profile; an operator can create accounts and flip the
runtime switches. There are still no rooms and no real /sync.

userapi: accounts, argon2id passwords in PHC format, devices, access and
refresh tokens stored as SHA-256 hashes, account data and the admin flag.
Its storage interface is storage.UserStore, implemented once in sqlstore
with `?` placeholders; the postgres driver supplies a rebind function and
unique-violation recognition rather than keeping a second copy of thirty
statements. Timestamps in these tables are int64 milliseconds, the unit the
Client-Server API uses on the wire.

Client-Server API: register (user-interactive auth with m.login.dummy and
optional registration tokens), register/available, token validity, login
with m.login.password, logout, logout/all, refresh, account/whoami,
capabilities, devices/* with password-gated deletion, profile/*, global and
per-room account data.

GET /sync is a deliberate placeholder: it answers a valid token with an
empty but well-formed response and refuses a revoked one. Phase 1 has no
rooms, so that is the whole truth, and it is what makes session revocation
observable. Phase 4 replaces it with the syncapi.

Auth middleware resolves a bearer token (header or the legacy query
parameter) to a device and user, answering an expired token with the soft
logout the spec asks for. Rate limiting is a token bucket per endpoint class
keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms.

adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix
token, GET/PUT /federation and /registration driving internal/settings and
reporting a config-pinned switch as a 409, GET/POST /users, GET /stats.

warp create-account [--admin] creates the first account straight in the
database, so a new install with registration off has a way in.

Complement is wired up for the federation=off profile: an image meeting its
contract, an entrypoint that configures Warp entirely from WARP_*
variables, and a runner that builds, clones and executes the target set
(TestRegistration, TestLogin, TestDeviceManagement). It has not been run
yet -- there is no container runtime on the development machine -- so the
three tests' assertions were read from the Complement sources and pinned as
httptest tests instead, and CI gained a job that runs the real thing.

Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern,
meant for the built binary, also matched the directory server/cmd/warp and
silently excluded the entire command package from the repository. The
binary patterns are now anchored to the repository root, and cmd/warp is
committed for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 18:18:37 +02:00
1 parent b4fb60a8ab
commit 6004bb01de
68 files changed
+9729 -66

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
package userapi
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2id parameters. These are OWASP's recommended settings for argon2id
// (19 MiB, two passes, one lane): strong enough to make a stolen hash
// expensive to attack, cheap enough that a login does not become a denial of
// service against the server itself.
//
// The values are recorded in every hash, so raising them later is a matter
// of changing these constants; old hashes keep verifying with the parameters
// they were made with, and are rewritten the next time the password changes.
const (
argonMemoryKiB = 19 * 1024
argonTime = 2
argonLanes = 1
argonSaltLen = 16
argonKeyLen = 32
)
// MaxPasswordLength caps a password at 512 bytes. Argon2id does not care how
// long its input is, but accepting unbounded input means accepting unbounded
// work in the hash function.
const MaxPasswordLength = 512
// ErrPasswordTooLong is returned when a password exceeds MaxPasswordLength.
var ErrPasswordTooLong = fmt.Errorf("password must be at most %d bytes", MaxPasswordLength)
// ErrInvalidPasswordHash is returned when a stored hash cannot be parsed. It
// means the row is corrupt, not that the password was wrong.
var ErrInvalidPasswordHash = errors.New("stored password hash is malformed")
// HashPassword returns an encoded argon2id hash in the PHC string format:
//
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
//
// The format is the de facto standard for password hashes, so the parameters
// travel with the hash and an operator can tell at a glance what a row was
// written with.
func HashPassword(password string) (string, error) {
if len(password) > MaxPasswordLength {
return "", ErrPasswordTooLong
}
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate password salt: %w", err)
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemoryKiB, argonLanes, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemoryKiB, argonTime, argonLanes,
b64.EncodeToString(salt), b64.EncodeToString(key)), nil
}
// VerifyPassword reports whether password matches encoded. An unparseable
// hash is an error rather than a mismatch, so a corrupt row shows up in the
// logs instead of looking like a wrong password forever.
func VerifyPassword(encoded, password string) (bool, error) {
if len(password) > MaxPasswordLength {
return false, ErrPasswordTooLong
}
params, salt, want, err := decodeHash(encoded)
if err != nil {
return false, err
}
got := argon2.IDKey([]byte(password), salt, params.time, params.memory, params.lanes, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
// b64 is the unpadded base64 the PHC format uses.
var b64 = base64.RawStdEncoding
type argonParams struct {
memory uint32
time uint32
lanes uint8
}
// decodeHash parses a PHC argon2id string.
func decodeHash(encoded string) (argonParams, []byte, []byte, error) {
// "", "argon2id", "v=19", "m=...,t=...,p=...", salt, hash
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != "" {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if parts[1] != "argon2id" {
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported algorithm %q", ErrInvalidPasswordHash, parts[1])
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if version != argon2.Version {
return argonParams{}, nil, nil, fmt.Errorf("%w: unsupported argon2 version %d", ErrInvalidPasswordHash, version)
}
var p argonParams
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.time, &p.lanes); err != nil {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
if p.memory == 0 || p.time == 0 || p.lanes == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
salt, err := b64.DecodeString(parts[4])
if err != nil || len(salt) == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
key, err := b64.DecodeString(parts[5])
if err != nil || len(key) == 0 {
return argonParams{}, nil, nil, ErrInvalidPasswordHash
}
return p, salt, key, nil
}
// dummyPasswordHash is verified against when the named account does not
// exist or has no password, so that a login attempt costs the same either
// way. Without it, the time a rejection takes would tell an attacker which
// user names are real.
var dummyPasswordHash = func() string {
hash, err := HashPassword("warp: this password hashes nothing")
if err != nil {
// HashPassword only fails on a short read from crypto/rand, which
// at init time means the process cannot work at all.
panic("userapi: cannot build the dummy password hash: " + err.Error())
}
return hash
}()
+160
View File
@@ -0,0 +1,160 @@
package userapi
import (
"errors"
"strings"
"testing"
"golang.org/x/crypto/argon2"
)
func TestHashAndVerifyPassword(t *testing.T) {
const password = "correct horse battery staple"
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if !strings.HasPrefix(hash, "$argon2id$v=") {
t.Fatalf("hash %q is not in the PHC format", hash)
}
ok, err := VerifyPassword(hash, password)
if err != nil || !ok {
t.Fatalf("VerifyPassword of the right password = %v, %v", ok, err)
}
ok, err = VerifyPassword(hash, password+"!")
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Error("the wrong password verified")
}
}
// TestHashIsSalted is what makes a stolen table worth attacking one row at a
// time rather than all at once.
func TestHashIsSalted(t *testing.T) {
first, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
second, err := HashPassword("same password")
if err != nil {
t.Fatal(err)
}
if first == second {
t.Error("two hashes of the same password are identical, so the salt is not random")
}
}
// TestHashRecordsItsParameters is what lets the cost be raised later without
// invalidating every existing password.
func TestHashRecordsItsParameters(t *testing.T) {
hash, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
params, salt, key, err := decodeHash(hash)
if err != nil {
t.Fatalf("decodeHash of our own output: %v", err)
}
if params.memory != argonMemoryKiB || params.time != argonTime || params.lanes != argonLanes {
t.Errorf("recorded parameters = %+v, want the current constants", params)
}
if len(salt) != argonSaltLen || len(key) != argonKeyLen {
t.Errorf("salt is %d bytes and key %d bytes", len(salt), len(key))
}
}
// TestVerifyAgainstAnOlderCost proves the point of recording them: a hash
// made with weaker parameters still verifies.
func TestVerifyAgainstAnOlderCost(t *testing.T) {
const password = "from an older release"
// A hash as a previous, cheaper, configuration would have written it.
salt := []byte("0123456789abcdef")
key := argon2.IDKey([]byte(password), salt, 1, 8*1024, 1, 32)
hash := "$argon2id$v=19$m=8192,t=1,p=1$" + b64.EncodeToString(salt) + "$" + b64.EncodeToString(key)
ok, err := VerifyPassword(hash, password)
if err != nil || !ok {
t.Fatalf("VerifyPassword against older parameters = %v, %v", ok, err)
}
}
// TestVerifyRejectsMalformedHashes keeps a corrupt row from looking like a
// wrong password for ever.
func TestVerifyRejectsMalformedHashes(t *testing.T) {
good, err := HashPassword("x")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(good, "$")
for name, hash := range map[string]string{
"empty": "",
"not a hash": "hunter2",
"too few fields": "$argon2id$v=19$m=19456,t=2,p=1$" + parts[4],
"wrong algorithm": "$argon2i$v=19$" + strings.Join(parts[3:], "$"),
"unknown version": "$argon2id$v=16$" + strings.Join(parts[3:], "$"),
"unparseable params": "$argon2id$v=19$m=lots,t=2,p=1$" + parts[4] + "$" + parts[5],
"zero memory": "$argon2id$v=19$m=0,t=2,p=1$" + parts[4] + "$" + parts[5],
"bad base64 salt": "$argon2id$v=19$m=19456,t=2,p=1$not!base64$" + parts[5],
"empty key": "$argon2id$v=19$m=19456,t=2,p=1$" + parts[4] + "$",
} {
t.Run(name, func(t *testing.T) {
ok, err := VerifyPassword(hash, "x")
if ok {
t.Fatal("a malformed hash verified")
}
if !errors.Is(err, ErrInvalidPasswordHash) {
t.Errorf("error = %v, want ErrInvalidPasswordHash", err)
}
})
}
}
func TestPasswordLengthIsBounded(t *testing.T) {
long := strings.Repeat("x", MaxPasswordLength+1)
if _, err := HashPassword(long); !errors.Is(err, ErrPasswordTooLong) {
t.Errorf("HashPassword of an overlong password = %v, want ErrPasswordTooLong", err)
}
hash, err := HashPassword(strings.Repeat("x", MaxPasswordLength))
if err != nil {
t.Fatalf("a password at the limit must be accepted: %v", err)
}
if _, err := VerifyPassword(hash, long); !errors.Is(err, ErrPasswordTooLong) {
t.Errorf("VerifyPassword of an overlong password = %v, want ErrPasswordTooLong", err)
}
}
// TestNonASCIIPasswords: the hash takes bytes, so there is nothing to
// normalise, but a client sending one must not be refused.
func TestNonASCIIPasswords(t *testing.T) {
const password = "übers3kr1t 🔐"
hash, err := HashPassword(password)
if err != nil {
t.Fatal(err)
}
if ok, err := VerifyPassword(hash, password); err != nil || !ok {
t.Errorf("VerifyPassword = %v, %v", ok, err)
}
}
// TestDummyPasswordHashIsUsable: the dummy hash exists so that a login for
// an account that does not exist costs the same as one that does. If it ever
// stopped being a valid hash, that work would vanish and the timing would
// leak which user names are real.
func TestDummyPasswordHashIsUsable(t *testing.T) {
ok, err := VerifyPassword(dummyPasswordHash, "anything")
if err != nil {
t.Fatalf("the dummy hash is not verifiable: %v", err)
}
if ok {
t.Error("the dummy hash matched a password")
}
}
+100
View File
@@ -0,0 +1,100 @@
package userapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
)
// Token prefixes. Matrix tokens are opaque to clients, so the prefix is for
// our side: it makes a token recognisable in a log or a bug report, it lets
// secret scanners match on something, and it means a client that sends its
// refresh token where the access token belongs gets a clear rejection
// instead of a puzzling one.
const (
accessTokenPrefix = "warp_at_"
refreshTokenPrefix = "warp_rt_"
)
// tokenEntropyBytes is the random part of a token: 256 bits, the same order
// as the session cookies of anything else worth attacking.
const tokenEntropyBytes = 32
// deviceIDLength is how many characters a generated device ID has. Ten
// uppercase letters is the shape clients and users are used to seeing, and
// 26^10 is far more than a user will ever have devices.
const deviceIDLength = 10
// deviceIDAlphabet is uppercase ASCII only: device IDs are read aloud during
// device verification, so digits and letters that look alike are a nuisance.
const deviceIDAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// GenerateAccessToken returns a new access token.
func GenerateAccessToken() (string, error) { return generateToken(accessTokenPrefix) }
// GenerateRefreshToken returns a new refresh token.
func GenerateRefreshToken() (string, error) { return generateToken(refreshTokenPrefix) }
func generateToken(prefix string) (string, error) {
raw := make([]byte, tokenEntropyBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
// URL-safe and unpadded: tokens travel in query strings as well as in
// the Authorization header.
return prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}
// HashToken returns the stored form of a token: lowercase hex SHA-256.
//
// The database holds hashes rather than tokens so that a dump cannot be
// replayed as a set of live sessions. SHA-256 with no salt or stretching is
// the right choice here, unlike for passwords: the input is 256 bits of
// uniform randomness, so there is no dictionary to try, and the hash is on
// the path of every authenticated request.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// GenerateDeviceID returns a random device ID for a login that did not name
// one.
func GenerateDeviceID() (string, error) {
raw := make([]byte, deviceIDLength)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate device ID: %w", err)
}
out := make([]byte, deviceIDLength)
for i, b := range raw {
// The alphabet has 26 letters and a byte has 256 values, so the
// modulo is very slightly biased towards the first four letters.
// For a name whose only job is to be unique among a user's handful
// of devices, that is not worth a rejection loop.
out[i] = deviceIDAlphabet[int(b)%len(deviceIDAlphabet)]
}
return string(out), nil
}
// MaxDeviceIDLength caps a client-supplied device ID. The spec sets no
// limit, but the column is a primary key and an unbounded one invites abuse.
const MaxDeviceIDLength = 255
// ValidateDeviceID checks a client-supplied device ID. Device IDs are opaque
// strings, so the only rules are that it is non-empty, short enough, and
// free of control characters that would mangle a log line.
func ValidateDeviceID(deviceID string) error {
if deviceID == "" {
return fmt.Errorf("%w: device_id must not be empty", ErrInvalidDeviceID)
}
if len(deviceID) > MaxDeviceIDLength {
return fmt.Errorf("%w: device_id must be at most %d bytes", ErrInvalidDeviceID, MaxDeviceIDLength)
}
for _, r := range deviceID {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("%w: device_id must not contain control characters", ErrInvalidDeviceID)
}
}
return nil
}
+527
View File
@@ -0,0 +1,527 @@
// Package userapi owns accounts, passwords, devices, tokens and account
// data: everything that identifies a local user and their sessions.
//
// It is the component the Client-Server API handlers call for anything
// to do with who is making a request. The HTTP shapes live in clientapi;
// what is here is the part that would be the same behind any transport —
// which is also what makes `warp create-account` able to create an account
// without starting a server.
//
// Nothing in this package knows about rooms. Devices get their encryption
// keys in Phase 7 (keyserver) and their push rules in Phase 8.
package userapi
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The storage sentinels are re-exported
// rather than translated: there is one error value with two names, so
// errors.Is works whichever package a caller reaches for.
var (
ErrUserExists = storage.ErrUserExists
ErrNoSuchUser = storage.ErrNoSuchUser
ErrNoSuchDevice = storage.ErrNoSuchDevice
ErrNoSuchToken = storage.ErrNoSuchToken
ErrNoSuchAccountData = storage.ErrNoSuchAccountData
// ErrTokenExpired is an access token that was valid and has timed out.
// It is distinct from ErrNoSuchToken because the spec answers it with a
// soft logout: the client may keep its encryption state and log in
// again.
ErrTokenExpired = errors.New("access token has expired")
// ErrAccountDeactivated is a token belonging to an account that has
// been deactivated.
ErrAccountDeactivated = errors.New("account has been deactivated")
// ErrWrongPassword is a failed password check. It is deliberately the
// same error whether the account exists or not, so that callers cannot
// turn it into a user-enumeration oracle by accident.
ErrWrongPassword = errors.New("invalid password")
// ErrNoPassword is an account that has no password set and so cannot
// use m.login.password.
ErrNoPassword = errors.New("account has no password")
// ErrInvalidDeviceID is a client-supplied device ID that is empty, too
// long or contains control characters.
ErrInvalidDeviceID = errors.New("invalid device ID")
)
// API is the userapi component.
type API struct {
serverName string
store storage.UserStore
log *slog.Logger
now func() time.Time
accessTokenLifetime time.Duration
refreshTokenLifetime time.Duration
// lastSeen throttles the last-seen bookkeeping; see RecordActivity.
lastSeen sync.Map
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name; it becomes the domain part of
// every user ID this component hands out.
ServerName string
// Store is the persistence layer.
Store storage.UserStore
// AccessTokenLifetime expires issued access tokens. Zero issues tokens
// that never expire.
AccessTokenLifetime time.Duration
// RefreshTokenLifetime bounds how long a refresh token stays usable.
// Zero means no expiry.
RefreshTokenLifetime time.Duration
Logger *slog.Logger
// Now is the clock, so tests can control token expiry. Nil means
// time.Now.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*API, error) {
if opts.ServerName == "" {
return nil, errors.New("userapi: ServerName must not be empty")
}
if opts.Store == nil {
return nil, errors.New("userapi: Store must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
log: opts.Logger,
now: opts.Now,
accessTokenLifetime: opts.AccessTokenLifetime,
refreshTokenLifetime: opts.RefreshTokenLifetime,
}, nil
}
// ServerName returns the server name user IDs are built with.
func (a *API) ServerName() string { return a.serverName }
// nowMS is the current time in the unit the store uses.
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
// NewAccountRequest describes an account to create.
type NewAccountRequest struct {
// Localpart is normalised and validated by CreateAccount.
Localpart string
// Password may be empty, which creates an account that cannot log in
// with a password. `warp create-account` uses that for a placeholder
// account; the admin API does not offer it.
Password string
DisplayName string
Admin bool
}
// CreateAccount validates the request, hashes the password and stores the
// account. It returns ErrUserExists if the name is taken and
// ErrInvalidLocalpart if the name is not usable.
func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) {
localpart := NormaliseLocalpart(req.Localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return storage.Account{}, err
}
var hash string
if req.Password != "" {
var err error
if hash, err = HashPassword(req.Password); err != nil {
return storage.Account{}, err
}
}
acc, err := a.store.CreateAccount(ctx, storage.NewAccount{
Localpart: localpart,
PasswordHash: hash,
DisplayName: req.DisplayName,
Admin: req.Admin,
CreatedAtMS: a.nowMS(),
})
if err != nil {
return storage.Account{}, err
}
a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin)
return acc, nil
}
// Account reads an account by normalised localpart.
func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) {
return a.store.Account(ctx, localpart)
}
// AccountExists reports whether a localpart is taken. It is the answer
// behind /register/available, and deliberately does not distinguish a
// deactivated account: the name stays reserved.
func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) {
switch _, err := a.store.Account(ctx, localpart); {
case err == nil:
return true, nil
case errors.Is(err, ErrNoSuchUser):
return false, nil
default:
return false, err
}
}
// SetAdmin grants or revokes the admin flag.
func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error {
return a.store.SetAdmin(ctx, localpart, admin)
}
// SetPassword replaces an account's password.
func (a *API) SetPassword(ctx context.Context, localpart, password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
return a.store.SetPasswordHash(ctx, localpart, hash)
}
// CheckPassword verifies a password against an account.
//
// Every failure path costs one argon2id hash, including an account that does
// not exist or has no password, so the time a rejection takes says nothing
// about which user names are real.
func (a *API) CheckPassword(ctx context.Context, localpart, password string) error {
hash, err := a.store.PasswordHash(ctx, localpart)
switch {
case errors.Is(err, ErrNoSuchUser):
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrWrongPassword
case err != nil:
return err
}
if hash == "" {
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrNoPassword
}
ok, err := VerifyPassword(hash, password)
switch {
case errors.Is(err, ErrPasswordTooLong):
return ErrWrongPassword
case err != nil:
// A malformed hash is an operational problem, not a wrong password.
a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err)
return err
case !ok:
return ErrWrongPassword
}
return nil
}
// SessionRequest asks for a new device and its tokens.
type SessionRequest struct {
// Localpart must already be normalised and must exist.
Localpart string
// DeviceID is the client's choice. Empty means generate one. Naming a
// device that already exists re-uses it and revokes its old tokens.
DeviceID string
// DisplayName is initial_device_display_name: used only when the device
// is created.
DisplayName string
// WithRefreshToken issues a refresh token alongside the access token.
WithRefreshToken bool
IP string
UserAgent string
}
// Session is the result of a login, a registration or a token refresh: what
// the client needs to start making requests.
type Session struct {
UserID string
Localpart string
DeviceID string
AccessToken string
// RefreshToken is empty unless the client asked for one.
RefreshToken string
// ExpiresInMS is how long the access token is good for, or 0 when it
// does not expire.
ExpiresInMS int64
}
// CreateSession issues a device and tokens for an existing account.
func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) {
deviceID := req.DeviceID
if deviceID == "" {
var err error
if deviceID, err = GenerateDeviceID(); err != nil {
return Session{}, err
}
} else if err := ValidateDeviceID(deviceID); err != nil {
return Session{}, err
}
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
var refreshToken string
if req.WithRefreshToken {
if refreshToken, err = GenerateRefreshToken(); err != nil {
return Session{}, err
}
}
now := a.nowMS()
session := storage.NewSession{
Localpart: req.Localpart,
DeviceID: deviceID,
DisplayName: req.DisplayName,
CreatedAtMS: now,
AccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
LastSeenIP: req.IP,
UserAgent: req.UserAgent,
}
if refreshToken != "" {
session.RefreshTokenHash = HashToken(refreshToken)
}
device, err := a.store.CreateSession(ctx, session)
if err != nil {
return Session{}, err
}
return Session{
UserID: a.UserID(req.Localpart),
Localpart: req.Localpart,
DeviceID: device.ID,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// RefreshSession spends a refresh token and issues a new pair. It returns
// ErrNoSuchToken if the token is unknown or already spent.
func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) {
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
newRefreshToken, err := GenerateRefreshToken()
if err != nil {
return Session{}, err
}
now := a.nowMS()
info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: HashToken(refreshToken),
NewAccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
NewRefreshTokenHash: HashToken(newRefreshToken),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
RefreshedAtMS: now,
})
if err != nil {
return Session{}, err
}
if info.Deactivated {
return Session{}, ErrAccountDeactivated
}
return Session{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// Authenticated is the identity behind a request: what the auth middleware
// resolves an access token into and what every authenticated handler is
// given.
type Authenticated struct {
UserID string
Localpart string
DeviceID string
Admin bool
}
// Authenticate resolves an access token.
//
// It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one
// that has timed out, and ErrAccountDeactivated for a token whose account is
// gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and
// M_USER_DEACTIVATED respectively.
func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) {
if accessToken == "" {
return Authenticated{}, ErrNoSuchToken
}
info, err := a.store.TokenInfo(ctx, HashToken(accessToken))
if err != nil {
return Authenticated{}, err
}
if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() {
return Authenticated{}, ErrTokenExpired
}
if info.Deactivated {
return Authenticated{}, ErrAccountDeactivated
}
return Authenticated{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
Admin: info.Admin,
}, nil
}
// lastSeenInterval is how stale the last-seen record of a device may get.
// Recording it on every request would double the write load of the whole C-S
// API to keep a field nothing reads more often than a user opens their
// device list.
const lastSeenInterval = time.Minute
// RecordActivity updates a device's last-seen timestamp, address and user
// agent, at most once per lastSeenInterval per device. A failure is logged
// and swallowed: the request it belongs to has already been authorised, and
// bookkeeping must not turn into a 500.
func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) {
key := auth.Localpart + "\x00" + auth.DeviceID
now := a.now()
if previous, ok := a.lastSeen.Load(key); ok {
if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval {
return
}
}
a.lastSeen.Store(key, now)
if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil {
a.log.Warn("recording device activity failed",
"user_id", auth.UserID, "device_id", auth.DeviceID, "error", err)
}
}
// Device reads one of a user's devices.
func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
return a.store.Device(ctx, localpart, deviceID)
}
// Devices lists a user's devices.
func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
return a.store.Devices(ctx, localpart)
}
// SetDeviceDisplayName renames a device.
func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
return a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName)
}
// DeleteDevices revokes devices and their tokens. It is what /logout,
// /delete_devices and DELETE /devices/{id} all end up calling.
func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error {
if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil {
return err
}
a.forgetLastSeen(localpart, deviceIDs...)
return nil
}
// DeleteAllDevices revokes every device of a user: /logout/all.
func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error {
if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil {
return err
}
a.forgetLastSeen(localpart)
return nil
}
// forgetLastSeen drops the throttle entries for devices that no longer
// exist, so the map does not grow with every logout. With no device IDs it
// clears every entry of the user.
func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) {
if len(deviceIDs) > 0 {
for _, id := range deviceIDs {
a.lastSeen.Delete(localpart + "\x00" + id)
}
return
}
prefix := localpart + "\x00"
a.lastSeen.Range(func(key, _ any) bool {
if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix {
a.lastSeen.Delete(key)
}
return true
})
}
// SetDisplayName updates a user's profile display name.
func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error {
return a.store.SetDisplayName(ctx, localpart, displayName)
}
// SetAvatarURL updates a user's profile avatar.
func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
return a.store.SetAvatarURL(ctx, localpart, avatarURL)
}
// AccountData reads one account data blob; roomID is empty for global data.
func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
return a.store.AccountData(ctx, localpart, roomID, dataType)
}
// SetAccountData writes one account data blob.
func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
return a.store.SetAccountData(ctx, localpart, roomID, dataType, content)
}
// ListAccounts returns a page of accounts for the admin API.
func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
return a.store.ListAccounts(ctx, filter)
}
// Stats returns the aggregate user counts for the admin API.
func (a *API) Stats(ctx context.Context) (storage.Stats, error) {
return a.store.Stats(ctx)
}
// expiryMS turns a lifetime into an absolute deadline in the store's unit,
// or 0 for "never".
func expiryMS(nowMS int64, lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return nowMS + lifetime.Milliseconds()
}
// lifetimeMS is the expires_in_ms a client is told, or 0 for "never".
func lifetimeMS(lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return lifetime.Milliseconds()
}
+323
View File
@@ -0,0 +1,323 @@
package userapi_test
import (
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// fixedClock is a clock a test can wind forward, so token expiry does not
// have to be waited for.
type fixedClock struct{ at time.Time }
func (c *fixedClock) now() time.Time { return c.at }
func (c *fixedClock) add(d time.Duration) { c.at = c.at.Add(d) }
// newTestAPI builds a userapi on a throwaway SQLite database.
func newTestAPI(t *testing.T, tweak func(*userapi.Options)) (*userapi.API, *fixedClock) {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
clock := &fixedClock{at: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
opts := userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
Now: clock.now,
}
if tweak != nil {
tweak(&opts)
}
api, err := userapi.New(opts)
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
return api, clock
}
func TestNewRejectsIncompleteOptions(t *testing.T) {
if _, err := userapi.New(userapi.Options{Store: nil, ServerName: "x"}); err == nil {
t.Error("a userapi without a store must not be built")
}
if _, err := userapi.New(userapi.Options{ServerName: ""}); err == nil {
t.Error("a userapi without a server name must not be built")
}
}
func TestCreateAccountNormalisesAndValidates(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
acc, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "Alice",
Password: "sUp3rs3kr1t",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" {
t.Errorf("localpart = %q, want alice", acc.Localpart)
}
if api.UserID(acc.Localpart) != "@alice:warp.example" {
t.Errorf("UserID = %q", api.UserID(acc.Localpart))
}
// Normalising on the way in is what makes the name really taken.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "ALICE", Password: "x",
}); !errors.Is(err, userapi.ErrUserExists) {
t.Errorf("creating @alice twice = %v, want ErrUserExists", err)
}
for name, localpart := range map[string]string{
"empty": "",
"illegal rune": "not valid",
"non-ASCII": "ünïcode",
"colon": "a:b",
"too long": strings.Repeat("a", 250),
} {
t.Run(name, func(t *testing.T) {
_, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: localpart, Password: "x"})
if !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("CreateAccount(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestCheckPasswordRejectsEveryWayTheSame keeps the login path from becoming
// a user directory.
func TestCheckPasswordRejectsEveryWayTheSame(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "sUp3rs3kr1t",
}); err != nil {
t.Fatal(err)
}
// An account with no password: `warp create-account` can make one.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "nopassword"}); err != nil {
t.Fatal(err)
}
if err := api.CheckPassword(ctx, "alice", "sUp3rs3kr1t"); err != nil {
t.Errorf("the right password was rejected: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "wrong"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("wrong password = %v, want ErrWrongPassword", err)
}
// An unknown account is ErrWrongPassword, not ErrNoSuchUser: the
// caller must not be able to tell the difference.
if err := api.CheckPassword(ctx, "nobody", "sUp3rs3kr1t"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("unknown account = %v, want ErrWrongPassword", err)
}
if err := api.CheckPassword(ctx, "nopassword", "anything"); !errors.Is(err, userapi.ErrNoPassword) {
t.Errorf("passwordless account = %v, want ErrNoPassword", err)
}
}
func TestSetPassword(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "first",
}); err != nil {
t.Fatal(err)
}
if err := api.SetPassword(ctx, "alice", "second"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "second"); err != nil {
t.Errorf("the new password does not work: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "first"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Error("the old password still works")
}
}
func TestAuthenticate(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "x", Admin: true,
}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if session.UserID != "@alice:warp.example" || session.DeviceID == "" || session.AccessToken == "" {
t.Fatalf("CreateSession returned %+v", session)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if auth.UserID != session.UserID || auth.DeviceID != session.DeviceID || !auth.Admin {
t.Errorf("Authenticate = %+v", auth)
}
for name, token := range map[string]string{"empty": "", "unknown": "warp_at_nope"} {
t.Run(name, func(t *testing.T) {
if _, err := api.Authenticate(ctx, token); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("Authenticate(%q) = %v, want ErrNoSuchToken", token, err)
}
})
}
}
// TestAccessTokenExpiry covers the lifetime configuration and the reason
// expiry is reported separately: it becomes a soft logout, not a hard one.
func TestAccessTokenExpiry(t *testing.T) {
api, clock := newTestAPI(t, func(o *userapi.Options) {
o.AccessTokenLifetime = time.Hour
})
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if session.ExpiresInMS != time.Hour.Milliseconds() {
t.Errorf("ExpiresInMS = %d, want one hour", session.ExpiresInMS)
}
if _, err := api.Authenticate(ctx, session.AccessToken); err != nil {
t.Fatalf("a fresh token must work: %v", err)
}
clock.add(time.Hour + time.Second)
if _, err := api.Authenticate(ctx, session.AccessToken); !errors.Is(err, userapi.ErrTokenExpired) {
t.Errorf("Authenticate of an expired token = %v, want ErrTokenExpired", err)
}
}
func TestRefreshSessionRequiresARefreshToken(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
// A session that did not ask for one cannot be refreshed.
plain, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if plain.RefreshToken != "" {
t.Error("a session that did not ask for a refresh token got one")
}
if _, err := api.RefreshSession(ctx, "warp_rt_nope"); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("RefreshSession with an unknown token = %v, want ErrNoSuchToken", err)
}
withRefresh, err := api.CreateSession(ctx, userapi.SessionRequest{
Localpart: "alice", WithRefreshToken: true,
})
if err != nil {
t.Fatal(err)
}
refreshed, err := api.RefreshSession(ctx, withRefresh.RefreshToken)
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if refreshed.DeviceID != withRefresh.DeviceID {
t.Errorf("refreshing changed the device: %s to %s", withRefresh.DeviceID, refreshed.DeviceID)
}
if refreshed.AccessToken == withRefresh.AccessToken {
t.Error("refreshing returned the same access token")
}
}
// TestRecordActivityIsThrottled covers the decision not to write to the
// database on every authenticated request.
func TestRecordActivityIsThrottled(t *testing.T) {
api, clock := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice", IP: "10.0.0.1"})
if err != nil {
t.Fatal(err)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatal(err)
}
api.RecordActivity(ctx, auth, "10.0.0.2", "first")
device, err := api.Device(ctx, "alice", session.DeviceID)
if err != nil {
t.Fatal(err)
}
firstSeen := device.LastSeenMS
if device.LastSeenIP != "10.0.0.2" {
t.Errorf("last_seen_ip = %q, want 10.0.0.2", device.LastSeenIP)
}
// A second visit a moment later is not written.
clock.add(time.Second)
api.RecordActivity(ctx, auth, "10.0.0.3", "second")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.2" || device.LastSeenMS != firstSeen {
t.Errorf("a visit one second later was written: %+v", device)
}
// A later one is.
clock.add(2 * time.Minute)
api.RecordActivity(ctx, auth, "10.0.0.4", "third")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.4" || device.LastSeenMS <= firstSeen {
t.Errorf("a visit two minutes later was not written: %+v", device)
}
}
func TestAccountExists(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
exists, err := api.AccountExists(ctx, "alice")
if err != nil || exists {
t.Fatalf("AccountExists on an empty database = %v, %v", exists, err)
}
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
if exists, err = api.AccountExists(ctx, "alice"); err != nil || !exists {
t.Fatalf("AccountExists = %v, %v", exists, err)
}
}
+119
View File
@@ -0,0 +1,119 @@
package userapi
import (
"errors"
"fmt"
"strings"
)
// MaxUserIDLength is the spec's cap on a user ID, sigil and server name
// included.
const MaxUserIDLength = 255
// Errors from parsing and validating identifiers. Callers map these onto
// M_INVALID_USERNAME or M_UNKNOWN depending on the endpoint.
var (
// ErrInvalidLocalpart means the localpart is empty, too long, or
// contains a character the spec does not allow.
ErrInvalidLocalpart = errors.New("invalid user name")
// ErrInvalidUserID means the string is not a well-formed user ID.
ErrInvalidUserID = errors.New("invalid user ID")
// ErrForeignServer means a well-formed user ID belongs to another
// homeserver. Answering for it needs federation.
ErrForeignServer = errors.New("user ID belongs to another server")
)
// NormaliseLocalpart lower-cases a localpart.
//
// The spec's grammar for the localpart of a user ID allows only lowercase
// letters, so a client sending "User-UPPER" is asking for "user-upper"
// rather than making a mistake. Both registration and login normalise, which
// is also what Synapse does and what Complement checks.
func NormaliseLocalpart(localpart string) string {
return strings.ToLower(localpart)
}
// ValidateLocalpart checks a normalised localpart against the user ID
// grammar of the Client-Server API: lowercase letters, digits, and the
// characters . _ = - / +.
//
// serverName is needed because the 255-byte limit applies to the whole user
// ID, so how long a localpart may be depends on where it lives.
func ValidateLocalpart(localpart, serverName string) error {
if localpart == "" {
return fmt.Errorf("%w: must not be empty", ErrInvalidLocalpart)
}
if n := len("@" + localpart + ":" + serverName); n > MaxUserIDLength {
return fmt.Errorf("%w: the resulting user ID would be %d bytes, the limit is %d",
ErrInvalidLocalpart, n, MaxUserIDLength)
}
for _, r := range localpart {
if !isLocalpartRune(r) {
return fmt.Errorf("%w: %q is not allowed; use lowercase letters, digits or . _ = - / +",
ErrInvalidLocalpart, r)
}
}
return nil
}
// isLocalpartRune reports whether r may appear in a user ID localpart. The
// range is deliberately byte-oriented: the grammar is ASCII only, so any
// multi-byte rune falls through to false.
func isLocalpartRune(r rune) bool {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return true
case r == '.', r == '_', r == '=', r == '-', r == '/', r == '+':
return true
default:
return false
}
}
// UserID assembles "@localpart:serverName".
func (a *API) UserID(localpart string) string {
return "@" + localpart + ":" + a.serverName
}
// ParseLocalUserID turns a user identifier into a normalised localpart on
// this server.
//
// It accepts both forms clients send: a full "@user:server" ID and a bare
// localpart, which the login endpoint has always allowed. A full ID naming
// another server is ErrForeignServer — a distinct case from a malformed one,
// because that is the answer federation would change.
func (a *API) ParseLocalUserID(identifier string) (string, error) {
if identifier == "" {
return "", fmt.Errorf("%w: must not be empty", ErrInvalidUserID)
}
if !strings.HasPrefix(identifier, "@") {
localpart := NormaliseLocalpart(identifier)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return "", err
}
return localpart, nil
}
if len(identifier) > MaxUserIDLength {
return "", fmt.Errorf("%w: longer than %d bytes", ErrInvalidUserID, MaxUserIDLength)
}
localpart, serverName, ok := strings.Cut(identifier[1:], ":")
if !ok || serverName == "" {
return "", fmt.Errorf("%w: expected @localpart:servername", ErrInvalidUserID)
}
// Server names are compared case-insensitively for the same reason the
// federation allowlist is: every real deployment uses a DNS name, and
// treating "Warp.example" as somebody else would turn a client's
// capitalisation into a login failure.
if !strings.EqualFold(serverName, a.serverName) {
return "", fmt.Errorf("%w: %s", ErrForeignServer, serverName)
}
localpart = NormaliseLocalpart(localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return "", err
}
return localpart, nil
}
+203
View File
@@ -0,0 +1,203 @@
package userapi_test
import (
"errors"
"strings"
"testing"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
func TestNormaliseLocalpart(t *testing.T) {
for input, want := range map[string]string{
"alice": "alice",
"Alice": "alice",
"USER-UPPER": "user-upper",
"": "",
} {
if got := userapi.NormaliseLocalpart(input); got != want {
t.Errorf("NormaliseLocalpart(%q) = %q, want %q", input, got, want)
}
}
}
func TestValidateLocalpart(t *testing.T) {
// The grammar the Client-Server API gives for a user ID localpart.
for _, localpart := range []string{
"alice", "user-upper", "a", "0", "chrtestuserq", "chrtestuser3",
"chrtestuser.", "chrtestuser_", "chrtestuser=", "chrtestuser-",
"chrtestuser/", "chrtestuser+",
} {
if err := userapi.ValidateLocalpart(localpart, "warp.example"); err != nil {
t.Errorf("ValidateLocalpart(%q) = %v, want nil", localpart, err)
}
}
for name, localpart := range map[string]string{
"empty": "",
"uppercase": "Alice",
"space": "a b",
"exclamation": "a!b",
"quote": `a"b`,
"colon": "a:b",
"question": "a?b",
"backslash": `a\b`,
"at sign": "a@b",
"bracket": "a[b",
"brace": "a{b",
"pipe": "a|b",
"pound": "a£b",
"accent": "aéb",
"newline": "a\nb",
"apostrophe": "a'b",
"comma": "a,b",
} {
t.Run(name, func(t *testing.T) {
if err := userapi.ValidateLocalpart(localpart, "warp.example"); !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("ValidateLocalpart(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestValidateLocalpartLengthDependsOnTheServerName: the 255-byte limit is
// on the whole user ID, so a long server name leaves less room for a name.
func TestValidateLocalpartLengthDependsOnTheServerName(t *testing.T) {
const shortServer = "a.example"
// "@" + localpart + ":" + server must be at most 255 bytes.
longest := strings.Repeat("x", userapi.MaxUserIDLength-len("@:"+shortServer))
if err := userapi.ValidateLocalpart(longest, shortServer); err != nil {
t.Errorf("a localpart exactly at the limit was rejected: %v", err)
}
if err := userapi.ValidateLocalpart(longest+"x", shortServer); !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("a localpart one byte over the limit = %v, want ErrInvalidLocalpart", err)
}
// The same localpart on a longer server name no longer fits.
if err := userapi.ValidateLocalpart(longest, "a-much-longer-server-name.example"); err == nil {
t.Error("the limit must count the server name")
}
}
func TestParseLocalUserID(t *testing.T) {
api, _ := newTestAPI(t, nil)
for name, tc := range map[string]struct {
input string
want string
}{
"full user ID": {"@alice:warp.example", "alice"},
"bare localpart": {"alice", "alice"},
"uppercase localpart": {"@ALICE:warp.example", "alice"},
"uppercase bare": {"Alice", "alice"},
"uppercase server name": {"@alice:WARP.Example", "alice"},
} {
t.Run(name, func(t *testing.T) {
got, err := api.ParseLocalUserID(tc.input)
if err != nil {
t.Fatalf("ParseLocalUserID(%q) = %v", tc.input, err)
}
if got != tc.want {
t.Errorf("ParseLocalUserID(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
// A user ID on another server is its own case: it is well formed, and
// federation is what would change the answer.
if _, err := api.ParseLocalUserID("@bob:other.example"); !errors.Is(err, userapi.ErrForeignServer) {
t.Errorf("a remote user ID = %v, want ErrForeignServer", err)
}
for name, input := range map[string]string{
"empty": "",
"no server name": "@alice",
"empty server": "@alice:",
"illegal rune": "@not valid:warp.example",
"bare illegal": "not valid",
} {
t.Run(name, func(t *testing.T) {
if _, err := api.ParseLocalUserID(input); err == nil {
t.Errorf("ParseLocalUserID(%q) returned no error", input)
}
})
}
}
func TestValidateDeviceID(t *testing.T) {
for _, deviceID := range []string{"ABCDEFGHIJ", "my_device_id", "device with spaces", "日本語"} {
if err := userapi.ValidateDeviceID(deviceID); err != nil {
t.Errorf("ValidateDeviceID(%q) = %v, want nil (device IDs are opaque)", deviceID, err)
}
}
for name, deviceID := range map[string]string{
"empty": "",
"too long": strings.Repeat("x", userapi.MaxDeviceIDLength+1),
"newline": "a\nb",
"control character": "a\x00b",
} {
t.Run(name, func(t *testing.T) {
if err := userapi.ValidateDeviceID(deviceID); !errors.Is(err, userapi.ErrInvalidDeviceID) {
t.Errorf("ValidateDeviceID(%q) = %v, want ErrInvalidDeviceID", deviceID, err)
}
})
}
}
func TestGenerateDeviceID(t *testing.T) {
seen := map[string]bool{}
for range 100 {
id, err := userapi.GenerateDeviceID()
if err != nil {
t.Fatalf("GenerateDeviceID: %v", err)
}
if err := userapi.ValidateDeviceID(id); err != nil {
t.Fatalf("a generated device ID is invalid: %v", err)
}
if seen[id] {
t.Fatalf("GenerateDeviceID repeated %q within 100 calls", id)
}
seen[id] = true
}
}
func TestTokensAreDistinctAndHashed(t *testing.T) {
access, err := userapi.GenerateAccessToken()
if err != nil {
t.Fatal(err)
}
refresh, err := userapi.GenerateRefreshToken()
if err != nil {
t.Fatal(err)
}
// The prefixes exist so that a client sending the wrong one gets a
// clear rejection, and so a token is recognisable in a log.
if !strings.HasPrefix(access, "warp_at_") {
t.Errorf("access token %q has no recognisable prefix", access)
}
if !strings.HasPrefix(refresh, "warp_rt_") {
t.Errorf("refresh token %q has no recognisable prefix", refresh)
}
hash := userapi.HashToken(access)
if hash == access || len(hash) != 64 {
t.Errorf("HashToken(%q) = %q, want a 64-character hex digest", access, hash)
}
if userapi.HashToken(access) != hash {
t.Error("HashToken is not deterministic")
}
if userapi.HashToken(refresh) == hash {
t.Error("two different tokens hashed the same")
}
second, err := userapi.GenerateAccessToken()
if err != nil {
t.Fatal(err)
}
if second == access {
t.Error("two generated access tokens are identical")
}
}