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
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:
1 parent
b4fb60a8ab
commit
6004bb01de
68 files changed
+9729
-66
No files matched your search
@@ -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
|
||||
}()
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user