138 lines
4.9 KiB
Go
138 lines
4.9 KiB
Go
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)
|
|
})
|
|
}
|