Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup - Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access - Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal) - Login rate limiting (exponential backoff 1s–30s cap) per IP - Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping) - Every account can manage every other account (no roles in v1) - Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check - Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed - Account tab for personal token management; sign-out button in topbar - Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrTokenNotFound is returned for an unknown, revoked, or not-owned token.
|
||||
var ErrTokenNotFound = errors.New("token not found")
|
||||
|
||||
// APIToken is a personal access token, scoped to the account that created it.
|
||||
// The plaintext is only ever returned once, by Create.
|
||||
type APIToken struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
Hint string `json:"hint"` // last 4 characters, for telling tokens apart in a list
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Tokens is a JSON-backed collection of personal API tokens.
|
||||
type Tokens struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
// items holds every token by id.
|
||||
items map[string]APIToken
|
||||
// byHash maps a token's sha256 hex digest to its id, for authentication
|
||||
// lookups without ever storing the plaintext.
|
||||
byHash map[string]string
|
||||
}
|
||||
|
||||
// NewTokens loads (or creates) the tokens file at path.
|
||||
func NewTokens(path string) (*Tokens, error) {
|
||||
t := &Tokens{path: path, items: map[string]APIToken{}, byHash: map[string]string{}}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return t, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read tokens: %w", err)
|
||||
}
|
||||
var list []APIToken
|
||||
if err := json.Unmarshal(b, &list); err != nil {
|
||||
return nil, fmt.Errorf("parse tokens file %s: %w", path, err)
|
||||
}
|
||||
for _, tok := range list {
|
||||
t.items[tok.ID] = tok
|
||||
t.byHash[tok.Hash] = tok.ID
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// List returns every token owned by userID, hash stripped, newest first.
|
||||
func (t *Tokens) List(userID string) []APIToken {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
out := make([]APIToken, 0, len(t.items))
|
||||
for _, tok := range t.items {
|
||||
if tok.UserID == userID {
|
||||
out = append(out, redactToken(tok))
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Create mints a new token for userID and returns it (hash stripped) plus the
|
||||
// plaintext secret, which is never stored and never retrievable again.
|
||||
func (t *Tokens) Create(userID, name string) (APIToken, string, error) {
|
||||
plain, err := newTokenSecret()
|
||||
if err != nil {
|
||||
return APIToken{}, "", fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
hash := hashToken(plain)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
tok := APIToken{
|
||||
ID: newID(),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Hint: plain[len(plain)-4:],
|
||||
Hash: hash,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
t.items[tok.ID] = tok
|
||||
t.byHash[hash] = tok.ID
|
||||
|
||||
if err := t.flush(); err != nil {
|
||||
return APIToken{}, "", err
|
||||
}
|
||||
return redactToken(tok), plain, nil
|
||||
}
|
||||
|
||||
// Authenticate looks up the token behind a plaintext secret and records it as
|
||||
// used. It does not check ownership: any valid token authenticates as its
|
||||
// owner, which the caller then treats as the request's identity.
|
||||
func (t *Tokens) Authenticate(plain string) (APIToken, error) {
|
||||
hash := hashToken(plain)
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
id, ok := t.byHash[hash]
|
||||
if !ok {
|
||||
return APIToken{}, ErrTokenNotFound
|
||||
}
|
||||
tok := t.items[id]
|
||||
now := time.Now()
|
||||
tok.LastUsedAt = &now
|
||||
t.items[id] = tok
|
||||
if err := t.flush(); err != nil {
|
||||
return APIToken{}, err
|
||||
}
|
||||
return redactToken(tok), nil
|
||||
}
|
||||
|
||||
// Revoke deletes a token, refusing if it is not owned by userID.
|
||||
func (t *Tokens) Revoke(userID, id string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
tok, ok := t.items[id]
|
||||
if !ok || tok.UserID != userID {
|
||||
return ErrTokenNotFound
|
||||
}
|
||||
delete(t.items, id)
|
||||
delete(t.byHash, tok.Hash)
|
||||
return t.flush()
|
||||
}
|
||||
|
||||
// RevokeAllForUser deletes every token owned by userID, e.g. when the account
|
||||
// itself is deleted.
|
||||
func (t *Tokens) RevokeAllForUser(userID string) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
for id, tok := range t.items {
|
||||
if tok.UserID == userID {
|
||||
delete(t.items, id)
|
||||
delete(t.byHash, tok.Hash)
|
||||
}
|
||||
}
|
||||
return t.flush()
|
||||
}
|
||||
|
||||
// flush writes the file. The caller must hold the write lock.
|
||||
func (t *Tokens) flush() error {
|
||||
list := make([]APIToken, 0, len(t.items))
|
||||
for _, tok := range t.items {
|
||||
list = append(list, tok)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := t.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return fmt.Errorf("write tokens: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, t.path); err != nil {
|
||||
return fmt.Errorf("replace tokens file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactToken(tok APIToken) APIToken {
|
||||
tok.Hash = ""
|
||||
return tok
|
||||
}
|
||||
|
||||
// newTokenSecret generates a token in the form dmv_<48 hex characters>.
|
||||
func newTokenSecret() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "dmv_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func hashToken(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestTokens(t *testing.T) (*Tokens, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "tokens.json")
|
||||
tk, err := NewTokens(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokens: %v", err)
|
||||
}
|
||||
return tk, path
|
||||
}
|
||||
|
||||
func TestTokensCreateAndAuthenticate(t *testing.T) {
|
||||
tk, _ := newTestTokens(t)
|
||||
|
||||
tok, plain, err := tk.Create("user-1", "ci token")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if tok.Hash != "" {
|
||||
t.Fatal("Create must return a redacted token")
|
||||
}
|
||||
if !strings.HasPrefix(plain, "dmv_") {
|
||||
t.Fatalf("plaintext = %q, want dmv_ prefix", plain)
|
||||
}
|
||||
if tok.Hint != plain[len(plain)-4:] {
|
||||
t.Fatalf("hint = %q, want last 4 chars of %q", tok.Hint, plain)
|
||||
}
|
||||
|
||||
got, err := tk.Authenticate(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if got.UserID != "user-1" {
|
||||
t.Fatalf("UserID = %q, want user-1", got.UserID)
|
||||
}
|
||||
if got.Hash != "" {
|
||||
t.Fatal("Authenticate must return a redacted token")
|
||||
}
|
||||
|
||||
if _, err := tk.Authenticate("dmv_deadbeef"); err != ErrTokenNotFound {
|
||||
t.Fatalf("Authenticate with bogus token = %v, want ErrTokenNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensAuthenticateRecordsLastUsed(t *testing.T) {
|
||||
tk, _ := newTestTokens(t)
|
||||
|
||||
tok, plain, err := tk.Create("user-1", "ci token")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if tok.LastUsedAt != nil {
|
||||
t.Fatal("a fresh token should have no last-used time")
|
||||
}
|
||||
got, err := tk.Authenticate(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if got.LastUsedAt == nil {
|
||||
t.Fatal("Authenticate should record LastUsedAt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensListScopedToOwner(t *testing.T) {
|
||||
tk, _ := newTestTokens(t)
|
||||
|
||||
if _, _, err := tk.Create("user-1", "a"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, _, err := tk.Create("user-2", "b"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
list := tk.List("user-1")
|
||||
if len(list) != 1 || list[0].Name != "a" {
|
||||
t.Fatalf("List(user-1) = %+v, want just token a", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensRevokeOwnershipChecked(t *testing.T) {
|
||||
tk, _ := newTestTokens(t)
|
||||
|
||||
tok, plain, err := tk.Create("user-1", "a")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
if err := tk.Revoke("user-2", tok.ID); err != ErrTokenNotFound {
|
||||
t.Fatalf("Revoke by non-owner = %v, want ErrTokenNotFound", err)
|
||||
}
|
||||
if err := tk.Revoke("user-1", tok.ID); err != nil {
|
||||
t.Fatalf("Revoke by owner: %v", err)
|
||||
}
|
||||
if _, err := tk.Authenticate(plain); err != ErrTokenNotFound {
|
||||
t.Fatalf("Authenticate after revoke = %v, want ErrTokenNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensRevokeAllForUser(t *testing.T) {
|
||||
tk, _ := newTestTokens(t)
|
||||
|
||||
if _, _, err := tk.Create("user-1", "a"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, _, err := tk.Create("user-1", "b"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, _, err := tk.Create("user-2", "c"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
if err := tk.RevokeAllForUser("user-1"); err != nil {
|
||||
t.Fatalf("RevokeAllForUser: %v", err)
|
||||
}
|
||||
if len(tk.List("user-1")) != 0 {
|
||||
t.Fatal("user-1 should have no tokens left")
|
||||
}
|
||||
if len(tk.List("user-2")) != 1 {
|
||||
t.Fatal("user-2's token should be untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokensPersistAcrossReload(t *testing.T) {
|
||||
tk, path := newTestTokens(t)
|
||||
|
||||
_, plain, err := tk.Create("user-1", "a")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := NewTokens(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokens: %v", err)
|
||||
}
|
||||
if _, err := reopened.Authenticate(plain); err != nil {
|
||||
t.Fatalf("Authenticate after reload: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ErrUsernameTaken is returned by Create when the username is already in use.
|
||||
var ErrUsernameTaken = errors.New("username already taken")
|
||||
|
||||
// ErrInvalidCredentials is returned by Verify on an unknown username or a
|
||||
// wrong password. The two cases are not distinguished, to avoid leaking which
|
||||
// usernames exist.
|
||||
var ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
|
||||
// ErrLastAccount is returned by Delete when it would remove the only account.
|
||||
var ErrLastAccount = errors.New("cannot delete the last account")
|
||||
|
||||
// ErrPasswordTooLong is returned when a password exceeds bcrypt's 72-byte
|
||||
// limit. Passwords are rejected rather than silently truncated.
|
||||
var ErrPasswordTooLong = errors.New("password must be 72 bytes or fewer")
|
||||
|
||||
// User is one local account.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"passwordHash"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
}
|
||||
|
||||
// Users is a JSON-backed collection of local accounts.
|
||||
type Users struct {
|
||||
path string
|
||||
mu sync.RWMutex
|
||||
// items holds every account by id.
|
||||
items map[string]User
|
||||
// byName maps a lowercased username to its account id, for case-insensitive
|
||||
// uniqueness checks and login lookups.
|
||||
byName map[string]string
|
||||
}
|
||||
|
||||
// dummyHash is compared against on a lookup-miss in Verify, so a login attempt
|
||||
// against a username that doesn't exist takes the same time as one that does.
|
||||
var dummyHash = mustHash("dockmv-dummy-password-for-timing-only")
|
||||
|
||||
func mustHash(password string) []byte {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// NewUsers loads (or creates) the users file at path.
|
||||
func NewUsers(path string) (*Users, error) {
|
||||
u := &Users{path: path, items: map[string]User{}, byName: map[string]string{}}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return u, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read users: %w", err)
|
||||
}
|
||||
var list []User
|
||||
if err := json.Unmarshal(b, &list); err != nil {
|
||||
return nil, fmt.Errorf("parse users file %s: %w", path, err)
|
||||
}
|
||||
for _, usr := range list {
|
||||
u.items[usr.ID] = usr
|
||||
u.byName[strings.ToLower(usr.Username)] = usr.ID
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Count returns the number of accounts.
|
||||
func (u *Users) Count() int {
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
return len(u.items)
|
||||
}
|
||||
|
||||
// List returns every account, password hashes stripped, username order.
|
||||
func (u *Users) List() []User {
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
out := make([]User, 0, len(u.items))
|
||||
for _, usr := range u.items {
|
||||
out = append(out, redactUser(usr))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns one account, password hash stripped.
|
||||
func (u *Users) Get(id string) (User, error) {
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
usr, ok := u.items[id]
|
||||
if !ok {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
return redactUser(usr), nil
|
||||
}
|
||||
|
||||
// Create adds a new account. Usernames are unique case-insensitively.
|
||||
func (u *Users) Create(username, password string) (User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
return User{}, errors.New("username is required")
|
||||
}
|
||||
if password == "" {
|
||||
return User{}, errors.New("password is required")
|
||||
}
|
||||
if len(password) > 72 {
|
||||
return User{}, ErrPasswordTooLong
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
key := strings.ToLower(username)
|
||||
if _, ok := u.byName[key]; ok {
|
||||
return User{}, ErrUsernameTaken
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
usr := User{ID: newID(), Username: username, PasswordHash: string(hash), CreatedAt: time.Now()}
|
||||
u.items[usr.ID] = usr
|
||||
u.byName[key] = usr.ID
|
||||
|
||||
if err := u.flush(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return redactUser(usr), nil
|
||||
}
|
||||
|
||||
// Verify checks a username/password pair and returns the account on success.
|
||||
// A lookup-miss still runs a bcrypt comparison against a dummy hash, so the
|
||||
// two failure cases take the same time.
|
||||
func (u *Users) Verify(username, password string) (User, error) {
|
||||
u.mu.RLock()
|
||||
id, ok := u.byName[strings.ToLower(strings.TrimSpace(username))]
|
||||
var usr User
|
||||
if ok {
|
||||
usr = u.items[id]
|
||||
}
|
||||
u.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(usr.PasswordHash), []byte(password)); err != nil {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
return redactUser(usr), nil
|
||||
}
|
||||
|
||||
// TouchLastLogin records the current time as an account's last login.
|
||||
func (u *Users) TouchLastLogin(id string) error {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
usr, ok := u.items[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
usr.LastLoginAt = &now
|
||||
u.items[id] = usr
|
||||
return u.flush()
|
||||
}
|
||||
|
||||
// Delete removes an account. The last remaining account cannot be deleted, so
|
||||
// the instance never ends up with no way to log in.
|
||||
func (u *Users) Delete(id string) error {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
usr, ok := u.items[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if len(u.items) == 1 {
|
||||
return ErrLastAccount
|
||||
}
|
||||
delete(u.items, id)
|
||||
delete(u.byName, strings.ToLower(usr.Username))
|
||||
return u.flush()
|
||||
}
|
||||
|
||||
// flush writes the file. The caller must hold the write lock.
|
||||
func (u *Users) flush() error {
|
||||
list := make([]User, 0, len(u.items))
|
||||
for _, usr := range u.items {
|
||||
list = append(list, usr)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := u.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return fmt.Errorf("write users: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, u.path); err != nil {
|
||||
return fmt.Errorf("replace users file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redactUser(usr User) User {
|
||||
usr.PasswordHash = ""
|
||||
return usr
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestUsers(t *testing.T) (*Users, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "users.json")
|
||||
u, err := NewUsers(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUsers: %v", err)
|
||||
}
|
||||
return u, path
|
||||
}
|
||||
|
||||
func TestUsersCreateAndVerify(t *testing.T) {
|
||||
u, _ := newTestUsers(t)
|
||||
|
||||
usr, err := u.Create("Alice", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if usr.PasswordHash != "" {
|
||||
t.Fatal("Create must return a redacted user")
|
||||
}
|
||||
if usr.ID == "" {
|
||||
t.Fatal("an id should have been generated")
|
||||
}
|
||||
|
||||
if _, err := u.Verify("alice", "correct-password"); err != nil {
|
||||
t.Fatalf("Verify with matching case-insensitive username: %v", err)
|
||||
}
|
||||
if _, err := u.Verify("Alice", "wrong-password"); err != ErrInvalidCredentials {
|
||||
t.Fatalf("Verify with wrong password = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
if _, err := u.Verify("nobody", "correct-password"); err != ErrInvalidCredentials {
|
||||
t.Fatalf("Verify with unknown username = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersUsernameUniqueCaseInsensitive(t *testing.T) {
|
||||
u, _ := newTestUsers(t)
|
||||
|
||||
if _, err := u.Create("Bob", "password1"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := u.Create("bob", "password2"); err != ErrUsernameTaken {
|
||||
t.Fatalf("Create with case-different duplicate = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersValidation(t *testing.T) {
|
||||
u, _ := newTestUsers(t)
|
||||
|
||||
if _, err := u.Create("", "password"); err == nil {
|
||||
t.Fatal("empty username should be rejected")
|
||||
}
|
||||
if _, err := u.Create("carol", ""); err == nil {
|
||||
t.Fatal("empty password should be rejected")
|
||||
}
|
||||
if _, err := u.Create("dave", strings.Repeat("x", 73)); err != ErrPasswordTooLong {
|
||||
t.Fatalf("73-byte password = %v, want ErrPasswordTooLong", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersDeleteRefusesLastAccount(t *testing.T) {
|
||||
u, _ := newTestUsers(t)
|
||||
|
||||
a, err := u.Create("alice", "password1")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
b, err := u.Create("bob", "password2")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
if err := u.Delete(a.ID); err != nil {
|
||||
t.Fatalf("Delete first account: %v", err)
|
||||
}
|
||||
if err := u.Delete(b.ID); err != ErrLastAccount {
|
||||
t.Fatalf("Delete last account = %v, want ErrLastAccount", err)
|
||||
}
|
||||
if u.Count() != 1 {
|
||||
t.Fatalf("Count = %d, want 1", u.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersPersistAcrossReload(t *testing.T) {
|
||||
u, path := newTestUsers(t)
|
||||
|
||||
if _, err := u.Create("alice", "correct-password"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := NewUsers(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewUsers: %v", err)
|
||||
}
|
||||
if reopened.Count() != 1 {
|
||||
t.Fatalf("Count after reload = %d, want 1", reopened.Count())
|
||||
}
|
||||
if _, err := reopened.Verify("alice", "correct-password"); err != nil {
|
||||
t.Fatalf("Verify after reload: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersTouchLastLogin(t *testing.T) {
|
||||
u, _ := newTestUsers(t)
|
||||
|
||||
usr, err := u.Create("alice", "correct-password")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if usr.LastLoginAt != nil {
|
||||
t.Fatal("a fresh account should have no last login")
|
||||
}
|
||||
if err := u.TouchLastLogin(usr.ID); err != nil {
|
||||
t.Fatalf("TouchLastLogin: %v", err)
|
||||
}
|
||||
got, err := u.Get(usr.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.LastLoginAt == nil {
|
||||
t.Fatal("LastLoginAt should be set after TouchLastLogin")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user