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.
169 lines
4.5 KiB
Go
169 lines
4.5 KiB
Go
// 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)
|
|
}
|
|
}
|