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