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:
2026-08-22 22:58:18 +01:00
parent cf51a6be94
commit 2ffdf77905
24 changed files with 2850 additions and 37 deletions
+168
View File
@@ -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)
}
}