feat(admin): sessions, staff auth and adminctl

One Redis session store and one cookie for all three identities. Staff
login returns the same error for every failure mode and spends a bcrypt
comparison against a dummy hash when no user exists, so neither the message
nor the timing confirms which addresses have accounts.

Staff users are created only by adminctl. There is no signup endpoint: a
licensing authority that can be joined over the internet is not one.

Pins gin and go-redis to the versions server/ already uses rather than the
latest tidy would pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-25 19:05:28 +01:00
co-authored by Claude Opus 5
parent a636a48e07
commit 480a578deb
8 changed files with 496 additions and 3 deletions
+82
View File
@@ -0,0 +1,82 @@
// Command adminctl performs the operations that deliberately have no HTTP
// surface.
//
// adminctl staff-add --email=you@example.com --name="You" --password=...
//
// There is no staff signup endpoint. A licensing authority that can be joined
// over the internet is not one.
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"golang.org/x/crypto/bcrypt"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
cfg, err := config.Load()
if err != nil {
fatal("configuration: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.Connect(ctx, cfg); err != nil {
fatal("database: %v", err)
}
switch os.Args[1] {
case "staff-add":
staffAdd(ctx, os.Args[2:])
default:
fmt.Fprintln(os.Stderr, "usage: adminctl staff-add")
os.Exit(2)
}
}
func staffAdd(ctx context.Context, args []string) {
fs := flag.NewFlagSet("staff-add", flag.ExitOnError)
email := fs.String("email", "", "staff email (required)")
name := fs.String("name", "", "display name")
password := fs.String("password", "", "password, at least 12 characters (required)")
fs.Parse(args)
if *email == "" || len(*password) < 12 {
fatal("--email and a --password of at least 12 characters are required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(*password), 12)
if err != nil {
fatal("hash: %v", err)
}
u := models.StaffUser{
UserID: uuid.NewString(),
Email: strings.ToLower(strings.TrimSpace(*email)),
PasswordHash: string(hash),
Name: *name,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Admin("staff_users").InsertOne(ctx, u); err != nil {
fatal("create staff user: %v", err)
}
fmt.Printf("created staff user %s\n", u.Email)
}
func fatal(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}