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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user