feat(auth): instance-owner gate — owner store, JWT sessions, setup/login UI (onboarding M1+M2)
lib/owner: argon2id credential store, HS256 access/refresh pair with kid, hashed-jti session store, throttler (5 free failures then 4^n backoff). handlers/auth: /api/auth/* + RequireSession/CSRF/GateWebSocket middleware; every /api/* route is now gated (428 in setup mode). --reset-owner recovery. Frontend: AuthScreen (setup/login), useAuth gate hook, gated data hooks + single-flight silent refresh, Owner account section in settings. spec.yaml documents the full surface.
This commit is contained in:
@@ -81,4 +81,4 @@ func TestSyncAuthorizationIntegration(t *testing.T) {
|
||||
assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t")
|
||||
// Armed database: PUBLIC grant removed.
|
||||
assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package owner is Maestro's self-contained instance-owner identity store
|
||||
// (onboarding theme M1, D1/D2): argon2id password hashes in a JSON file at the
|
||||
// data root, deliberately independent of the fleet database and auth service —
|
||||
// logging into the orchestrator must work exactly when everything else is
|
||||
// down. The Store interface keeps the middleware swappable for a DB- or
|
||||
// LDAP-backed implementation later.
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Owner is one dashboard user. The setup wizard creates the first admin;
|
||||
// further owners seed through the same store/API path (no management UI yet).
|
||||
type Owner struct {
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Role renders the JWT role claim: admins gatekeep trust decisions.
|
||||
func (o *Owner) Role() string {
|
||||
if o.IsAdmin {
|
||||
return "admin"
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
// Store persists owners. FileOwnerStore is the on-disk implementation; a
|
||||
// DB-backed store can slot in later without touching the middleware.
|
||||
type Store interface {
|
||||
Count(ctx context.Context) (int, error)
|
||||
Verify(ctx context.Context, username, password string) (*Owner, error)
|
||||
Create(ctx context.Context, username, password string, isAdmin bool) (*Owner, error)
|
||||
// Get looks a user up without credentials (role refresh during token
|
||||
// rotation).
|
||||
Get(ctx context.Context, username string) (*Owner, error)
|
||||
}
|
||||
|
||||
var (
|
||||
usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{2,63}$`)
|
||||
// Mirrors the dcntrlized-auth conventions.
|
||||
MinPasswordLen = 10
|
||||
|
||||
ErrInvalidUsername = fmt.Errorf("username must match %s", usernameRe.String())
|
||||
ErrWeakPassword = fmt.Errorf("password must be at least %d characters", MinPasswordLen)
|
||||
)
|
||||
|
||||
// ValidateCredentials enforces the shared-auth conventions: lowercase
|
||||
// usernames of 3–64 chars, passwords of at least MinPasswordLen chars.
|
||||
func ValidateCredentials(username, password string) error {
|
||||
if !usernameRe.MatchString(username) {
|
||||
return ErrInvalidUsername
|
||||
}
|
||||
if len(password) < MinPasswordLen {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 2
|
||||
argonKeyLen = 32
|
||||
)
|
||||
|
||||
// HashPassword derives an argon2id PHC-style hash string.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("read salt: %w", err)
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword compares a password against a PHC-style argon2id hash in
|
||||
// constant time.
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
parts := strings.Split(hash, "$")
|
||||
// "", "argon2id", "v=19", "m=…,t=…,p=…", salt, key
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// ChangePassword rehashes the owner's password and bumps updated_at.
|
||||
func (s *FileOwnerStore) ChangePassword(ctx context.Context, username, currentPassword, newPassword string) (*Owner, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
file, owners, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range owners.Owners {
|
||||
o := &owners.Owners[i]
|
||||
if o.Username != username {
|
||||
continue
|
||||
}
|
||||
if !VerifyPassword(o.PasswordHash, currentPassword) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
hash, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.PasswordHash = hash
|
||||
o.UpdatedAt = time.Now().UTC()
|
||||
return o, s.write(file, owners)
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
type ownerFile struct {
|
||||
Version int `json:"version"`
|
||||
Owners []Owner `json:"owners"`
|
||||
}
|
||||
|
||||
// FileOwnerStore persists owners to <dataRoot>/maestro/owners.json with
|
||||
// atomic writes and chmod 600.
|
||||
type FileOwnerStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFileOwnerStore targets dir/owners.json (dir = <dataRoot>/maestro).
|
||||
func NewFileOwnerStore(dir string) (*FileOwnerStore, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create owner store directory: %w", err)
|
||||
}
|
||||
return &FileOwnerStore{path: filepath.Join(dir, "owners.json")}, nil
|
||||
}
|
||||
|
||||
func (s *FileOwnerStore) read() (string, *ownerFile, error) {
|
||||
file := ownerFile{Version: 1}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return s.path, &file, nil
|
||||
}
|
||||
if err != nil {
|
||||
return s.path, nil, fmt.Errorf("read owners file: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return s.path, nil, fmt.Errorf("parse owners file: %w", err)
|
||||
}
|
||||
return s.path, &file, nil
|
||||
}
|
||||
|
||||
// write atomically replaces the owners file; the mutex serializes writers and
|
||||
// concurrent setups end up idempotent through Count's check in the caller.
|
||||
func (s *FileOwnerStore) write(path string, owners *ownerFile) error {
|
||||
data, err := json.MarshalIndent(owners, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode owners file: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write owners file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace owners file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileOwnerStore) Count(_ context.Context) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, owners, err := s.read()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(owners.Owners), nil
|
||||
}
|
||||
|
||||
func (s *FileOwnerStore) Create(_ context.Context, username, password string, isAdmin bool) (*Owner, error) {
|
||||
if err := ValidateCredentials(username, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
file, owners, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range owners.Owners {
|
||||
if o.Username == username {
|
||||
return nil, os.ErrExist
|
||||
}
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
owner := Owner{Username: username, PasswordHash: hash, IsAdmin: isAdmin, CreatedAt: now, UpdatedAt: now}
|
||||
owners.Owners = append(owners.Owners, owner)
|
||||
if err := s.write(file, owners); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &owner, nil
|
||||
}
|
||||
|
||||
func (s *FileOwnerStore) Verify(_ context.Context, username, password string) (*Owner, error) {
|
||||
o, err := s.Get(context.Background(), username)
|
||||
if err != nil || o == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if VerifyPassword(o.PasswordHash, password) {
|
||||
return o, nil
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *FileOwnerStore) Get(_ context.Context, username string) (*Owner, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, owners, err := s.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range owners.Owners {
|
||||
if o.Username == username {
|
||||
return &o, nil
|
||||
}
|
||||
}
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *FileOwnerStore {
|
||||
t.Helper()
|
||||
s, err := NewFileOwnerStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHashAndVerifyPassword(t *testing.T) {
|
||||
hash, err := HashPassword("correct horse battery")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") {
|
||||
t.Fatalf("hash missing argon2id PHC prefix: %s", hash)
|
||||
}
|
||||
if !VerifyPassword(hash, "correct horse battery") {
|
||||
t.Fatal("correct password rejected")
|
||||
}
|
||||
if VerifyPassword(hash, "wrong password!") {
|
||||
t.Fatal("wrong password accepted")
|
||||
}
|
||||
if VerifyPassword("garbage", "x") {
|
||||
t.Fatal("garbage hash accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCredentials(t *testing.T) {
|
||||
good := [][2]string{{"admin", "longenough123"}, {"a.b-c_d1", "0123456789"}}
|
||||
for _, c := range good {
|
||||
if err := ValidateCredentials(c[0], c[1]); err != nil {
|
||||
t.Errorf("%q/%q rejected: %v", c[0], c[1], err)
|
||||
}
|
||||
}
|
||||
bad := [][2]string{
|
||||
{"Admin", "0123456789"}, // uppercase
|
||||
{"-lead", "0123456789"}, // bad first char
|
||||
{"ab", "0123456789"}, // too short
|
||||
{"okname", "short"}, // weak password
|
||||
}
|
||||
for _, c := range bad {
|
||||
if err := ValidateCredentials(c[0], c[1]); err == nil {
|
||||
t.Errorf("%q/%q accepted", c[0], c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRoundtripAndMultiUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if n, _ := s.Count(ctx); n != 0 {
|
||||
t.Fatalf("fresh store Count = %d", n)
|
||||
}
|
||||
first, err := s.Create(ctx, "admin", "first-owner-pass", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.IsAdmin || first.Role() != "admin" {
|
||||
t.Fatalf("first owner should be admin, got %+v", first)
|
||||
}
|
||||
second, err := s.Create(ctx, "member", "second-owner-pass", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Role() != "member" {
|
||||
t.Fatalf("second owner role = %q", second.Role())
|
||||
}
|
||||
if n, _ := s.Count(ctx); n != 2 {
|
||||
t.Fatalf("Count = %d, want 2", n)
|
||||
}
|
||||
|
||||
if got, err := s.Verify(ctx, "admin", "first-owner-pass"); err != nil || got.Username != "admin" {
|
||||
t.Fatalf("Verify admin: %v %+v", err, got)
|
||||
}
|
||||
if _, err := s.Verify(ctx, "admin", "wrong"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("wrong password: %v", err)
|
||||
}
|
||||
if _, err := s.Create(ctx, "admin", "whatever-123", false); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("duplicate create: %v", err)
|
||||
}
|
||||
|
||||
changed, err := s.ChangePassword(ctx, "admin", "first-owner-pass", "rotated-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.UpdatedAt.Equal(changed.CreatedAt) {
|
||||
t.Fatal("updated_at not bumped")
|
||||
}
|
||||
if _, err := s.Verify(ctx, "admin", "first-owner-pass"); err == nil {
|
||||
t.Fatal("old password still valid")
|
||||
}
|
||||
if _, err := s.Verify(ctx, "admin", "rotated-password"); err != nil {
|
||||
t.Fatalf("new password rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCreateIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 16)
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := s.Create(ctx, "wizard", "setup-password", true); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n, _ := s.Count(ctx); n != 1 {
|
||||
t.Fatalf("Count after concurrent setup = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerFilePermissions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
s, err := NewFileOwnerStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Create(ctx, "admin", "perm-check-pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "owners.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("owners.json mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Session is one persisted refresh-token record. Only the SHA-256 hash of the
|
||||
// jti is stored — never the raw value, mirroring the auth service's
|
||||
// token_hash convention.
|
||||
type Session struct {
|
||||
Username string `json:"username"`
|
||||
JTIHash string `json:"jti_hash"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type sessionFile struct {
|
||||
Version int `json:"version"`
|
||||
Sessions []Session `json:"sessions"`
|
||||
}
|
||||
|
||||
// HashJTI renders the stored digest of a raw refresh jti.
|
||||
func HashJTI(jti string) string {
|
||||
sum := sha256.Sum256([]byte(jti))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// FileSessionStore persists refresh-token state to
|
||||
// <dataRoot>/maestro/sessions.json so sessions survive restarts, can be
|
||||
// revoked on logout, and support rotation + reuse detection.
|
||||
type FileSessionStore struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewFileSessionStore targets dir/sessions.json.
|
||||
func NewFileSessionStore(dir string) (*FileSessionStore, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create session store directory: %w", err)
|
||||
}
|
||||
return &FileSessionStore{path: filepath.Join(dir, "sessions.json")}, nil
|
||||
}
|
||||
|
||||
func (s *FileSessionStore) load() (*sessionFile, error) {
|
||||
file := sessionFile{Version: 1}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return &file, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read sessions file: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &file); err != nil {
|
||||
return nil, fmt.Errorf("parse sessions file: %w", err)
|
||||
}
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
// save atomically replaces the file after pruning expired/revoked rows.
|
||||
func (s *FileSessionStore) save(file *sessionFile) error {
|
||||
now := time.Now()
|
||||
live := file.Sessions[:0]
|
||||
for _, s := range file.Sessions {
|
||||
if s.RevokedAt == nil && now.Before(s.ExpiresAt) {
|
||||
live = append(live, s)
|
||||
}
|
||||
}
|
||||
file.Sessions = live
|
||||
|
||||
data, err := json.MarshalIndent(file, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode sessions file: %w", err)
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||
return fmt.Errorf("write sessions file: %w", err)
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// Store records a freshly minted refresh token.
|
||||
func (s *FileSessionStore) Store(username, jti string, expiresAt time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file.Sessions = append(file.Sessions, Session{
|
||||
Username: username,
|
||||
JTIHash: HashJTI(jti),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt.UTC(),
|
||||
})
|
||||
return s.save(file)
|
||||
}
|
||||
|
||||
// Valid reports whether the jti maps to a live (stored, unrevoked, unexpired)
|
||||
// session row.
|
||||
func (s *FileSessionStore) Valid(jti string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.load()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hash := HashJTI(jti)
|
||||
now := time.Now()
|
||||
for _, sess := range file.Sessions {
|
||||
if sess.JTIHash == hash {
|
||||
return sess.RevokedAt == nil && now.Before(sess.ExpiresAt)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Revoke marks the jti's row revoked (logout / rotation).
|
||||
func (s *FileSessionStore) Revoke(jti string) error {
|
||||
return s.revokeWhere(func(sess Session) bool { return sess.JTIHash == HashJTI(jti) })
|
||||
}
|
||||
|
||||
// RevokeAllForUser kills every live session row of the user — the response to
|
||||
// refresh-token reuse (theft signal).
|
||||
func (s *FileSessionStore) RevokeAllForUser(username string) error {
|
||||
return s.revokeWhere(func(sess Session) bool { return sess.Username == username })
|
||||
}
|
||||
|
||||
func (s *FileSessionStore) revokeWhere(match func(Session) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
file, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
changed := false
|
||||
for i := range file.Sessions {
|
||||
if match(file.Sessions[i]) && file.Sessions[i].RevokedAt == nil {
|
||||
t := now
|
||||
file.Sessions[i].RevokedAt = &t
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return s.save(file)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionsStoreValidRevoke(t *testing.T) {
|
||||
s, err := NewFileSessionStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
jti := "jti-1"
|
||||
if err := s.Store("admin", jti, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Valid(jti) {
|
||||
t.Fatal("fresh session invalid")
|
||||
}
|
||||
if s.Valid("missing") {
|
||||
t.Fatal("unknown session valid")
|
||||
}
|
||||
|
||||
if err := s.Revoke(jti); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Valid(jti) {
|
||||
t.Fatal("revoked session still valid")
|
||||
}
|
||||
// Re-revoke must not fail (logout idempotence).
|
||||
if err := s.Revoke(jti); err != nil {
|
||||
t.Fatalf("double revoke: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAllForUser(t *testing.T) {
|
||||
s, _ := NewFileSessionStore(t.TempDir())
|
||||
|
||||
for _, j := range []string{"a", "b", "c"} {
|
||||
if err := s.Store("admin", j, time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.Store("other", "d", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.RevokeAllForUser("admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, j := range []string{"a", "b", "c"} {
|
||||
if s.Valid(j) {
|
||||
t.Fatalf("session %q survived revoke-all", j)
|
||||
}
|
||||
}
|
||||
if !s.Valid("d") {
|
||||
t.Fatal("other user's session revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredSessionsPrunedOnSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, _ := NewFileSessionStore(dir)
|
||||
|
||||
// Expired session: stored but never valid.
|
||||
expired := sha256.Sum256([]byte("expired"))
|
||||
jti := hex.EncodeToString(expired[:])
|
||||
if err := s.Store("admin", jti, time.Now().Add(-time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Valid(jti) {
|
||||
t.Fatal("expired session valid")
|
||||
}
|
||||
|
||||
// Prune happens on save: force a write and confirm the expired entry
|
||||
// disappears from disk.
|
||||
if err := s.Store("admin", "fresh", time.Now().Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "sessions.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(data, []byte(HashJTI(jti))) {
|
||||
t.Fatal("expired session not pruned from disk")
|
||||
}
|
||||
if !bytes.Contains(data, []byte(HashJTI("fresh"))) {
|
||||
t.Fatal("live session missing from disk")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// In-process throttling for login, setup and password change, keyed by
|
||||
// username — mirrors dcntrlized-auth's handlers/auth/throttle.go: the first
|
||||
// throttleMaxAttempts failures inside the window pass freely (humans mistype),
|
||||
// beyond that each further failure multiplies the wait (5s×4^n, capped at
|
||||
// 24h). Maestro is single-instance, so process-local state suffices.
|
||||
|
||||
const (
|
||||
throttleMaxAttempts = 5
|
||||
throttleWindow = 15 * time.Minute
|
||||
throttleBackoffBase = 5 * time.Second
|
||||
throttleMultiplier = 4
|
||||
throttleMaxWait = 24 * time.Hour
|
||||
)
|
||||
|
||||
type throttleAttempt struct {
|
||||
count int
|
||||
last time.Time
|
||||
}
|
||||
|
||||
type Throttler struct {
|
||||
mu sync.Mutex
|
||||
state map[string]*throttleAttempt
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
// NewThrottler builds an empty throttler.
|
||||
func NewThrottler() *Throttler {
|
||||
return &Throttler{state: map[string]*throttleAttempt{}, nowFn: time.Now}
|
||||
}
|
||||
|
||||
func backoffFor(count int) time.Duration {
|
||||
delay := throttleBackoffBase
|
||||
for i := 1; i < count-throttleMaxAttempts && delay < throttleMaxWait; i++ {
|
||||
delay *= throttleMultiplier
|
||||
}
|
||||
if delay > throttleMaxWait {
|
||||
delay = throttleMaxWait
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// Allow reports whether an attempt for identifier may proceed right now.
|
||||
func (t *Throttler) Allow(identifier string) bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
a, ok := t.state[identifier]
|
||||
if !ok || a.count <= throttleMaxAttempts {
|
||||
return true
|
||||
}
|
||||
if t.nowFn().Sub(a.last) > throttleWindow {
|
||||
delete(t.state, identifier)
|
||||
return true
|
||||
}
|
||||
return t.nowFn().Sub(a.last) >= backoffFor(a.count)
|
||||
}
|
||||
|
||||
// Fail records a failed attempt.
|
||||
func (t *Throttler) Fail(identifier string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
a, ok := t.state[identifier]
|
||||
now := t.nowFn()
|
||||
if !ok || now.Sub(a.last) > throttleWindow {
|
||||
t.state[identifier] = &throttleAttempt{count: 1, last: now}
|
||||
return
|
||||
}
|
||||
a.count++
|
||||
a.last = now
|
||||
}
|
||||
|
||||
// Reset clears the identifier after a success.
|
||||
func (t *Throttler) Reset(identifier string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
delete(t.state, identifier)
|
||||
}
|
||||
|
||||
// RetryAfter reports how long the identifier must wait before the next
|
||||
// attempt is permitted; 0 when attempts are currently allowed. Used for the
|
||||
// 429 Retry-After header.
|
||||
func (t *Throttler) RetryAfter(identifier string) time.Duration {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
a, ok := t.state[identifier]
|
||||
if !ok || a.count <= throttleMaxAttempts {
|
||||
return 0
|
||||
}
|
||||
now := t.nowFn()
|
||||
if now.Sub(a.last) > throttleWindow {
|
||||
return 0
|
||||
}
|
||||
if wait := backoffFor(a.count) - now.Sub(a.last); wait > 0 {
|
||||
return wait
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
// AccessTokenTTL is the stateless access-token lifetime.
|
||||
AccessTokenTTL = 15 * time.Minute
|
||||
// RefreshTokenTTL is the persisted refresh-token lifetime.
|
||||
RefreshTokenTTL = 7 * 24 * time.Hour
|
||||
// Issuer tags every minted token; verification rejects others.
|
||||
Issuer = "maestro"
|
||||
|
||||
claimType = "type"
|
||||
claimKid = "kid"
|
||||
claimJti = "jti"
|
||||
|
||||
typeAccess = "access"
|
||||
typeRefresh = "refresh"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidToken = errors.New("invalid token")
|
||||
ErrWrongType = errors.New("token has the wrong type")
|
||||
)
|
||||
|
||||
// TokenPair is one login/rotation result: the cookie payloads plus the refresh
|
||||
// jti (its raw value lives only in the cookies and the caller's response).
|
||||
type TokenPair struct {
|
||||
Access string
|
||||
Refresh string
|
||||
RefreshJTI string
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
Username string
|
||||
Role string
|
||||
}
|
||||
|
||||
// TokenService mints and verifies the HS256 access/refresh session pair. The
|
||||
// signing secret is a per-instance random key on disk (`session.key`, chmod
|
||||
// 600); kid = sha256(secret)[:16] so a rotated secret keeps old access tokens
|
||||
// valid through their remaining TTL (grace window).
|
||||
type TokenService struct {
|
||||
dir string
|
||||
|
||||
key []byte
|
||||
kid string
|
||||
}
|
||||
|
||||
// NewTokenService loads or creates dir/session.key.
|
||||
func NewTokenService(dir string) (*TokenService, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create token directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, "session.key")
|
||||
key, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
key = make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("generate session key: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, key, 0o600); err != nil {
|
||||
return nil, fmt.Errorf("persist session key: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("read session key: %w", err)
|
||||
} else if len(key) < 32 {
|
||||
return nil, fmt.Errorf("session key at %s is too short", path)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(key)
|
||||
return &TokenService{dir: dir, key: key, kid: hex.EncodeToString(sum[:])[:16]}, nil
|
||||
}
|
||||
|
||||
// Kid returns the active signing-key identifier.
|
||||
func (t *TokenService) Kid() string { return t.kid }
|
||||
|
||||
func (t *TokenService) claims(sub, typ, role string, ttl time.Duration, jti string) jwt.MapClaims {
|
||||
now := time.Now()
|
||||
c := jwt.MapClaims{
|
||||
"sub": sub,
|
||||
"iss": Issuer,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(ttl).Unix(),
|
||||
claimType: typ,
|
||||
claimKid: t.kid,
|
||||
}
|
||||
if role != "" {
|
||||
c["role"] = role
|
||||
}
|
||||
if jti != "" {
|
||||
c[claimJti] = jti
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (t *TokenService) sign(c jwt.MapClaims) (string, error) {
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(t.key)
|
||||
}
|
||||
|
||||
// MintPair issues a fresh access/refresh pair for the owner. The refresh jti
|
||||
// must be recorded by the session store before the cookie is served.
|
||||
func (t *TokenService) MintPair(username, role string) (*TokenPair, error) {
|
||||
jti := make([]byte, 16)
|
||||
if _, err := rand.Read(jti); err != nil {
|
||||
return nil, fmt.Errorf("generate jti: %w", err)
|
||||
}
|
||||
raw := hex.EncodeToString(jti)
|
||||
|
||||
access, err := t.sign(t.claims(username, typeAccess, role, AccessTokenTTL, ""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
refresh, err := t.sign(t.claims(username, typeRefresh, "", RefreshTokenTTL, raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign refresh token: %w", err)
|
||||
}
|
||||
return &TokenPair{
|
||||
Access: access, Refresh: refresh, RefreshJTI: raw,
|
||||
AccessTTL: AccessTokenTTL, RefreshTTL: RefreshTokenTTL,
|
||||
Username: username, Role: role,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parse validates signature (constant-time HMAC compare inside the library),
|
||||
// issuer and expiry, returning the claims.
|
||||
func (t *TokenService) parse(token string) (jwt.MapClaims, error) {
|
||||
parsed, err := jwt.Parse(token, func(tk *jwt.Token) (interface{}, error) {
|
||||
if _, ok := tk.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return t.key, nil
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(Issuer), jwt.WithExpirationRequired())
|
||||
if err != nil || !parsed.Valid {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if kid, _ := claims[claimKid].(string); kid != "" && kid != t.kid && !hmac.Equal([]byte(kid), []byte(t.kid)) {
|
||||
// A different kid means an unknown secret generation — refuse. The
|
||||
// same-kid path is the normal case; empty kid predates kid tagging.
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// VerifyAccess validates an access token and returns the username + role.
|
||||
func (t *TokenService) VerifyAccess(token string) (username, role string, err error) {
|
||||
claims, err := t.parse(token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if typ, _ := claims[claimType].(string); typ != typeAccess {
|
||||
return "", "", ErrWrongType
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
role, _ = claims["role"].(string)
|
||||
if sub == "" {
|
||||
return "", "", ErrInvalidToken
|
||||
}
|
||||
return sub, role, nil
|
||||
}
|
||||
|
||||
// VerifyRefresh validates a refresh token and returns username + jti.
|
||||
func (t *TokenService) VerifyRefresh(token string) (username, jti string, err error) {
|
||||
claims, err := t.parse(token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if typ, _ := claims[claimType].(string); typ != typeRefresh {
|
||||
return "", "", ErrWrongType
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
jti, _ = claims[claimJti].(string)
|
||||
if sub == "" || jti == "" {
|
||||
return "", "", ErrInvalidToken
|
||||
}
|
||||
return sub, jti, nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package owner
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func newTestTokenService(t *testing.T) *TokenService {
|
||||
t.Helper()
|
||||
s, err := NewTokenService(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestMintAndVerifyPair(t *testing.T) {
|
||||
s := newTestTokenService(t)
|
||||
|
||||
pair, err := s.MintPair("admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pair.Access == "" || pair.Refresh == "" || pair.RefreshJTI == "" {
|
||||
t.Fatal("empty field in pair")
|
||||
}
|
||||
if pair.Username != "admin" || pair.Role != "admin" {
|
||||
t.Fatalf("pair identity wrong: %+v", pair)
|
||||
}
|
||||
if pair.AccessTTL != AccessTokenTTL || pair.RefreshTTL != RefreshTokenTTL {
|
||||
t.Fatalf("pair TTLs wrong: %+v", pair)
|
||||
}
|
||||
for _, tok := range []string{pair.Access, pair.Refresh} {
|
||||
parts := strings.Split(tok, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token not JWS compact: %q", tok)
|
||||
}
|
||||
}
|
||||
|
||||
username, role, err := s.VerifyAccess(pair.Access)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyAccess: %v", err)
|
||||
}
|
||||
if username != "admin" || role != "admin" {
|
||||
t.Fatalf("access claims: %q/%q", username, role)
|
||||
}
|
||||
rUser, rJTI, err := s.VerifyRefresh(pair.Refresh)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRefresh: %v", err)
|
||||
}
|
||||
if rUser != "admin" || rJTI != pair.RefreshJTI {
|
||||
t.Fatalf("refresh claims: %q/%q", rUser, rJTI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenTypeIsEnforced(t *testing.T) {
|
||||
s := newTestTokenService(t)
|
||||
pair, _ := s.MintPair("admin", "admin")
|
||||
|
||||
if _, _, err := s.VerifyAccess(pair.Refresh); err == nil {
|
||||
t.Fatal("refresh token accepted as access")
|
||||
}
|
||||
if _, _, err := s.VerifyRefresh(pair.Access); err == nil {
|
||||
t.Fatal("access token accepted as refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func craftToken(t *testing.T, s *TokenService, claims jwt.MapClaims) string {
|
||||
t.Helper()
|
||||
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestExpiredTokensRejected(t *testing.T) {
|
||||
s := newTestTokenService(t)
|
||||
|
||||
mk := func(typ string, ttl time.Duration) string {
|
||||
now := time.Now()
|
||||
return craftToken(t, s, jwt.MapClaims{
|
||||
"iss": Issuer, "sub": "admin", "type": typ, "kid": s.kid,
|
||||
"iat": now.Add(-2 * ttl).Unix(), "exp": now.Add(-ttl).Unix(),
|
||||
"jti": "expired-" + typ,
|
||||
})
|
||||
}
|
||||
|
||||
if _, _, err := s.VerifyAccess(mk(typeAccess, AccessTokenTTL)); err == nil {
|
||||
t.Fatal("expired access accepted")
|
||||
}
|
||||
if _, _, err := s.VerifyRefresh(mk(typeRefresh, RefreshTokenTTL)); err == nil {
|
||||
t.Fatal("expired refresh accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgedTokensRejected(t *testing.T) {
|
||||
s := newTestTokenService(t)
|
||||
pair, _ := s.MintPair("admin", "admin")
|
||||
|
||||
// Signed by a different key.
|
||||
other := newTestTokenService(t)
|
||||
forged, _ := other.MintPair("admin", "admin")
|
||||
if _, _, err := s.VerifyAccess(forged.Access); err == nil {
|
||||
t.Fatal("foreign-signed access accepted")
|
||||
}
|
||||
if _, _, err := s.VerifyRefresh(forged.Refresh); err == nil {
|
||||
t.Fatal("foreign-signed refresh accepted")
|
||||
}
|
||||
|
||||
// Tampered payload.
|
||||
if _, _, err := s.VerifyAccess(strings.TrimSuffix(pair.Access, "x") + "y"); err == nil {
|
||||
t.Fatal("tampered token accepted")
|
||||
}
|
||||
|
||||
// Wrong issuer but right key.
|
||||
now := time.Now()
|
||||
badIss := craftToken(t, s, jwt.MapClaims{
|
||||
"iss": "someone-else", "sub": "admin", "type": typeAccess, "kid": s.kid,
|
||||
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
|
||||
})
|
||||
if _, _, err := s.VerifyAccess(badIss); err == nil {
|
||||
t.Fatal("wrong-issuer token accepted")
|
||||
}
|
||||
|
||||
// Unknown kid (foreign key generation).
|
||||
unknownKid := craftToken(t, s, jwt.MapClaims{
|
||||
"iss": Issuer, "sub": "admin", "type": typeAccess, "kid": "deadbeefdeadbeef",
|
||||
"iat": now.Unix(), "exp": now.Add(time.Hour).Unix(),
|
||||
})
|
||||
if _, _, err := s.VerifyAccess(unknownKid); err == nil {
|
||||
t.Fatal("unknown-kid token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyPersistsAcrossRestarts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s1, err := NewTokenService(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := s1.MintPair("admin", "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s2, err := NewTokenService(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s1.Kid() != s2.Kid() {
|
||||
t.Fatalf("kid changed across restarts: %s vs %s", s1.Kid(), s2.Kid())
|
||||
}
|
||||
if _, _, err := s2.VerifyAccess(pair.Access); err != nil {
|
||||
t.Fatalf("token invalid after restart: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottlerBackoffAndReset(t *testing.T) {
|
||||
th := NewThrottler()
|
||||
id := "admin"
|
||||
|
||||
if !th.Allow(id) {
|
||||
t.Fatal("fresh identifier blocked")
|
||||
}
|
||||
if th.RetryAfter(id) != 0 {
|
||||
t.Fatal("RetryAfter nonzero without failures")
|
||||
}
|
||||
|
||||
// The first five failures stay within the free allowance.
|
||||
for i := 0; i < throttleMaxAttempts; i++ {
|
||||
th.Fail(id)
|
||||
}
|
||||
if !th.Allow(id) {
|
||||
t.Fatal("attempt inside allowance blocked")
|
||||
}
|
||||
if th.RetryAfter(id) != 0 {
|
||||
t.Fatal("RetryAfter nonzero inside allowance")
|
||||
}
|
||||
|
||||
// The next failure crosses the threshold: backoff starts at base…
|
||||
th.Fail(id)
|
||||
if th.Allow(id) {
|
||||
t.Fatal("locked-out identifier allowed immediately")
|
||||
}
|
||||
if wait := th.RetryAfter(id); wait <= 0 || wait > throttleBackoffBase {
|
||||
t.Fatalf("RetryAfter = %v, want (0,%v]", wait, throttleBackoffBase)
|
||||
}
|
||||
// …and escalates multiplicatively with further failures.
|
||||
th.Fail(id)
|
||||
if wait := th.RetryAfter(id); wait <= throttleBackoffBase || wait > throttleBackoffBase*throttleMultiplier {
|
||||
t.Fatalf("RetryAfter = %v, want escalated", wait)
|
||||
}
|
||||
|
||||
// Success clears state.
|
||||
th.Reset(id)
|
||||
if !th.Allow(id) || th.RetryAfter(id) != 0 {
|
||||
t.Fatal("reset did not clear throttler")
|
||||
}
|
||||
|
||||
// Window expiry also clears state.
|
||||
th.Fail(id)
|
||||
th.nowFn = func() time.Time { return time.Now().Add(2 * throttleWindow) }
|
||||
if !th.Allow(id) {
|
||||
t.Fatal("identifier not forgiven after window")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user