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,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()
|
||||
}
|
||||
Reference in new issue
Block a user