This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/andrew/go-computer/internal/config"
|
||||
"github.com/andrew/go-computer/internal/db"
|
||||
"github.com/andrew/go-computer/internal/models"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const ViewAsCookie = "computer_view_as"
|
||||
|
||||
type SessionUser struct {
|
||||
ID, Role, RealID, RealRole string
|
||||
Name, Email *string
|
||||
RealUser map[string]any
|
||||
// Ver is the tokenVersion embedded in the credential (0 = no claim:
|
||||
// pre-revocation legacy cookies only — assertions must carry it), checked
|
||||
// against the user row so signout revokes cookie sessions AND NextAuth
|
||||
// assertions alike (review SEC-02).
|
||||
Ver int
|
||||
}
|
||||
|
||||
func identity(u *models.User) map[string]any {
|
||||
return map[string]any{"id": u.ID, "name": u.Name, "email": u.Email, "image": u.Image, "role": u.Role}
|
||||
}
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, e := ReadSession(r, config.Load().Secret())
|
||||
if e != nil {
|
||||
failure(w, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
repo := db.NewUserRepo(db.FromContext(r.Context()))
|
||||
u, e := repo.FindByID(r.Context(), s.ID)
|
||||
if e != nil {
|
||||
// Review CORR-03: a missing user is a credential problem (401),
|
||||
// but backend degradation must not masquerade as one — surface it
|
||||
// as 503 so client retry logic and monitoring see the difference.
|
||||
if errors.Is(e, db.ErrNotFound) {
|
||||
failure(w, 401, "Unauthorized")
|
||||
} else {
|
||||
log.Printf("auth: user lookup failed for session %s: %v", s.ID, e)
|
||||
failure(w, 503, "Service unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Revoked session: the credential's tokenVersion predates a signout
|
||||
// bump. Applies to cookie sessions and NextAuth assertions alike.
|
||||
if s.Ver > 0 && s.Ver != u.TokenVersion {
|
||||
failure(w, 401, "Unauthorized")
|
||||
return
|
||||
}
|
||||
// Promotion via ADMIN_EMAILS is persistent by design: removing an
|
||||
// address from the env var later does NOT demote the account (use the
|
||||
// Admin screen). Silent demotion on env drift would be worse than an
|
||||
// extra explicit revocation step.
|
||||
//
|
||||
// Code review SEC-04 flagged that this re-promotes an account demoted
|
||||
// via the Admin screen while its address remains in ADMIN_EMAILS. The
|
||||
// env-driven promotion is a deliberate architectural decision here —
|
||||
// demoting an operator account means editing ADMIN_EMAILS too; do not
|
||||
// "fix" this silently.
|
||||
if u.Role != "ADMIN" && u.Email != nil && u.EmailVerified != nil && config.IsAdminEmail(*u.Email) {
|
||||
if e = repo.SetRole(r.Context(), u.ID, "ADMIN"); e != nil {
|
||||
failure(w, 500, "Unable to resolve role")
|
||||
return
|
||||
}
|
||||
u.Role = "ADMIN"
|
||||
}
|
||||
s.RealID, s.RealRole, s.RealUser = u.ID, u.Role, identity(u)
|
||||
s.ID, s.Role, s.Name, s.Email = u.ID, u.Role, u.Name, u.Email
|
||||
if u.Role == "ADMIN" {
|
||||
if c, e := r.Cookie(ViewAsCookie); e == nil && c.Value != "" && c.Value != u.ID {
|
||||
if target, e := repo.FindByID(r.Context(), c.Value); e == nil {
|
||||
s.ID, s.Role, s.Name, s.Email = target.ID, target.Role, target.Name, target.Email
|
||||
// Review SEC-6: impersonation is audited. Log at most once
|
||||
// per admin/target/method combination per process so a
|
||||
// polling UI does not flood the log.
|
||||
auditViewAs(u.ID, target.ID, r.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Review SEC-6: view-as is read-only. Mutations while impersonating
|
||||
// are attributed to the target user, so writes must come from the
|
||||
// admin's own identity — exit view-as first. The view-as endpoint
|
||||
// itself is exempt: it controls the impersonation session and must
|
||||
// be writable to both start and stop the session.
|
||||
if s.ID != s.RealID && r.Method != http.MethodGet && r.Method != http.MethodHead && r.URL.Path != "/api/admin/view-as" {
|
||||
failure(w, 403, "Read-only while viewing as another user")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
|
||||
type sessionKey struct{}
|
||||
|
||||
// auditViewAs logs each distinct (admin, target, method) impersonation
|
||||
// observation once per process (review SEC-6). Ids only — never names,
|
||||
// emails, or request payloads.
|
||||
var (
|
||||
auditViewAsMu sync.Mutex
|
||||
auditViewAsLog = map[string]bool{}
|
||||
)
|
||||
|
||||
func auditViewAs(adminID, targetID, method string) {
|
||||
key := adminID + "|" + targetID + "|" + method
|
||||
auditViewAsMu.Lock()
|
||||
defer auditViewAsMu.Unlock()
|
||||
if auditViewAsLog[key] {
|
||||
return
|
||||
}
|
||||
auditViewAsLog[key] = true
|
||||
log.Printf("audit: admin %s viewing as %s (%s)", adminID, targetID, method)
|
||||
}
|
||||
|
||||
func WithSession(ctx context.Context, s *SessionUser) context.Context {
|
||||
return context.WithValue(ctx, sessionKey{}, s)
|
||||
}
|
||||
func SessionFromContext(ctx context.Context) (*SessionUser, bool) {
|
||||
s, ok := ctx.Value(sessionKey{}).(*SessionUser)
|
||||
return s, ok
|
||||
}
|
||||
func RequireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := SessionFromContext(r.Context())
|
||||
if !ok || s.RealRole != "ADMIN" {
|
||||
failure(w, 403, "Forbidden")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user