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
+58
View File
@@ -0,0 +1,58 @@
package auth
import (
"net/http"
"github.com/gin-gonic/gin"
)
const ctxSession = "admin_session_obj"
func load(c *gin.Context) *Session {
id, err := c.Cookie(CookieName)
if err != nil || id == "" {
return nil
}
s, err := Get(c.Request.Context(), id)
if err != nil {
return nil
}
return s
}
// Current returns the session, or nil.
func Current(c *gin.Context) *Session {
if v, ok := c.Get(ctxSession); ok {
if s, ok := v.(*Session); ok {
return s
}
}
return nil
}
func RequireStaff() gin.HandlerFunc {
return func(c *gin.Context) {
s := load(c)
if s == nil || s.Kind != KindStaff {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
c.Set(ctxSession, s)
c.Next()
}
}
// RequireCustomer admits both cloud and self-hosted customers. Every handler
// behind it scopes by AccountID via the helper in api/customer.go — never by
// remembering to filter.
func RequireCustomer() gin.HandlerFunc {
return func(c *gin.Context) {
s := load(c)
if s == nil || s.Kind != KindCustomer || s.AccountID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
return
}
c.Set(ctxSession, s)
c.Next()
}
}
+55
View File
@@ -0,0 +1,55 @@
package auth
import (
"sync"
"time"
)
// Thresholds from spec 3: 5 attempts per email per 15 minutes, 20 per IP per
// hour. The email limit stops a targeted attack on one account; the IP limit
// stops a spray across many.
const (
emailLimit = 5
emailWindow = 15 * time.Minute
ipLimit = 20
ipWindow = time.Hour
)
var (
attemptMu sync.Mutex
byEmail = map[string][]time.Time{}
byIP = map[string][]time.Time{}
)
func prune(in []time.Time, cutoff time.Time) []time.Time {
out := in[:0]
for _, t := range in {
if t.After(cutoff) {
out = append(out, t)
}
}
return out
}
func allowAttempt(email, ip string) bool {
now := time.Now()
attemptMu.Lock()
defer attemptMu.Unlock()
byEmail[email] = prune(byEmail[email], now.Add(-emailWindow))
byIP[ip] = prune(byIP[ip], now.Add(-ipWindow))
if len(byEmail[email]) >= emailLimit || len(byIP[ip]) >= ipLimit {
return false
}
byEmail[email] = append(byEmail[email], now)
byIP[ip] = append(byIP[ip], now)
return true
}
func clearAttempts(email string) {
attemptMu.Lock()
delete(byEmail, email)
attemptMu.Unlock()
}
+90
View File
@@ -0,0 +1,90 @@
// Package auth holds admin's three identities: staff, cloud customers and
// self-hosted customers. All three share one session store and one cookie.
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
const (
CookieName = "admin_session"
SessionTTL = 24 * time.Hour
KindStaff = "staff"
KindCustomer = "customer"
)
type Session struct {
UserID string `json:"user_id"`
Kind string `json:"kind"`
Email string `json:"email"`
AccountID string `json:"account_id,omitempty"` // customers only
}
var rdb *redis.Client
func InitRedis(addr string) { rdb = redis.NewClient(&redis.Options{Addr: addr}) }
func Ping(ctx context.Context) error { return rdb.Ping(ctx).Err() }
func newID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func Save(ctx context.Context, s Session) (string, error) {
id, err := newID()
if err != nil {
return "", err
}
body, err := json.Marshal(s)
if err != nil {
return "", err
}
if err := rdb.Set(ctx, "admin_session:"+id, body, SessionTTL).Err(); err != nil {
return "", err
}
return id, nil
}
func Get(ctx context.Context, id string) (*Session, error) {
body, err := rdb.Get(ctx, "admin_session:"+id).Bytes()
if err != nil {
return nil, err
}
var s Session
if err := json.Unmarshal(body, &s); err != nil {
return nil, err
}
return &s, nil
}
func Destroy(ctx context.Context, id string) { rdb.Del(ctx, "admin_session:"+id) }
func SetCookie(c *gin.Context, id string) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName,
Value: id,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(SessionTTL.Seconds()),
})
}
func ClearCookie(c *gin.Context) {
http.SetCookie(c.Writer, &http.Cookie{
Name: CookieName, Value: "", Path: "/", HttpOnly: true, Secure: true, MaxAge: -1,
})
}
+77
View File
@@ -0,0 +1,77 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/admin/internal/audit"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/crypto/bcrypt"
)
// genericAuthError is returned for every failure mode — unknown email, wrong
// password, wrong role. Distinguishing them would confirm which addresses have
// accounts.
const genericAuthError = "email or password is incorrect"
func HandleStaffLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password are required"})
return
}
email := strings.ToLower(strings.TrimSpace(body.Email))
if !allowAttempt(email, c.ClientIP()) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many attempts, try again later"})
return
}
var u models.StaffUser
err := db.Admin("staff_users").FindOne(c.Request.Context(), bson.M{"email": email}).Decode(&u)
if err != nil {
// Spend the same work as a real comparison so timing does not
// distinguish "no such user" from "wrong password".
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(body.Password))
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "unknown email"})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(body.Password)) != nil {
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login_failed", IP: c.ClientIP(), Detail: "bad password"})
c.JSON(http.StatusUnauthorized, gin.H{"error": genericAuthError})
return
}
id, err := Save(c.Request.Context(), Session{UserID: u.UserID, Kind: KindStaff, Email: u.Email})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session failed"})
return
}
SetCookie(c, id)
clearAttempts(email)
audit.Write(c.Request.Context(), models.AuditEntry{
Actor: email, Action: "staff.login", IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"kind": KindStaff, "email": u.Email, "name": u.Name})
}
// dummyHash is a valid bcrypt hash of a random value, compared against when no
// user exists so the timing profile matches.
const dummyHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEe.6qGZoAZQFtQmOoLmC5PbfW1uMh1Sv2u"
func HandleLogout(c *gin.Context) {
if id, err := c.Cookie(CookieName); err == nil && id != "" {
Destroy(c.Request.Context(), id)
}
ClearCookie(c)
c.JSON(http.StatusOK, gin.H{"ok": true})
}