169 lines
4.8 KiB
Go
169 lines
4.8 KiB
Go
package webui
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"mailgoserver/internal/db"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
sessionCookieName = "mailgoserver_session"
|
||
|
|
sessionTTL = 7 * 24 * time.Hour
|
||
|
|
)
|
||
|
|
|
||
|
|
type ctxKey int
|
||
|
|
|
||
|
|
const (
|
||
|
|
ctxUserKey ctxKey = iota
|
||
|
|
ctxScopeKey
|
||
|
|
)
|
||
|
|
|
||
|
|
// accessScope is which domains the current admin can see/manage. A global admin
|
||
|
|
// bypasses the domain-ID check entirely; a scoped admin is restricted to exactly the
|
||
|
|
// domains in DomainIDs — computed once per request in requireAuth and reused by every
|
||
|
|
// handler via scopeFromContext, rather than re-querying esrv_admin_domain_access
|
||
|
|
// repeatedly within the same request.
|
||
|
|
type accessScope struct {
|
||
|
|
Global bool
|
||
|
|
DomainIDs map[int64]bool
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s accessScope) Allowed(domainID int64) bool {
|
||
|
|
return s.Global || s.DomainIDs[domainID]
|
||
|
|
}
|
||
|
|
|
||
|
|
// IDs returns the accessible domain IDs as a slice — nil (not empty) for a global
|
||
|
|
// admin, since "nil" is the signal callers should treat as "no filter" rather than
|
||
|
|
// "empty set" when building an IN (...) clause or similar.
|
||
|
|
func (s accessScope) IDs() []int64 {
|
||
|
|
if s.Global {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
ids := make([]int64, 0, len(s.DomainIDs))
|
||
|
|
for id := range s.DomainIDs {
|
||
|
|
ids = append(ids, id)
|
||
|
|
}
|
||
|
|
return ids
|
||
|
|
}
|
||
|
|
|
||
|
|
func scopeFromContext(r *http.Request) accessScope {
|
||
|
|
s, _ := r.Context().Value(ctxScopeKey).(accessScope)
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
|
||
|
|
// requireDomainAccess checks the current admin's scope covers domainID; if not, it
|
||
|
|
// writes a 404 (not 403 — a scoped admin shouldn't be able to distinguish "doesn't
|
||
|
|
// exist" from "exists but isn't mine" by probing IDs) and returns false, matching the
|
||
|
|
// existing "not found" handling every route already does for a missing resource.
|
||
|
|
func requireDomainAccess(w http.ResponseWriter, r *http.Request, domainID int64) bool {
|
||
|
|
if scopeFromContext(r).Allowed(domainID) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
http.NotFound(w, r)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func (a *App) buildAccessScope(user *db.AdminUser) (accessScope, error) {
|
||
|
|
if user.IsGlobalAdmin {
|
||
|
|
return accessScope{Global: true}, nil
|
||
|
|
}
|
||
|
|
ids, err := a.DB.AccessibleDomainIDs(user.ID)
|
||
|
|
if err != nil {
|
||
|
|
return accessScope{}, err
|
||
|
|
}
|
||
|
|
m := make(map[int64]bool, len(ids))
|
||
|
|
for _, id := range ids {
|
||
|
|
m[id] = true
|
||
|
|
}
|
||
|
|
return accessScope{DomainIDs: m}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func setSessionCookie(w http.ResponseWriter, token string, secure bool) {
|
||
|
|
http.SetCookie(w, &http.Cookie{
|
||
|
|
Name: sessionCookieName,
|
||
|
|
Value: token,
|
||
|
|
Path: "/",
|
||
|
|
HttpOnly: true,
|
||
|
|
Secure: secure,
|
||
|
|
SameSite: http.SameSiteLaxMode,
|
||
|
|
MaxAge: int(sessionTTL.Seconds()),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func clearSessionCookie(w http.ResponseWriter) {
|
||
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
|
||
|
|
}
|
||
|
|
|
||
|
|
// currentSession loads the session + user for the request's cookie, if any and valid
|
||
|
|
// (exists, not expired). A nil session/user (no error) means "not logged in".
|
||
|
|
func (a *App) currentSession(r *http.Request) (*db.AdminSession, *db.AdminUser, error) {
|
||
|
|
c, err := r.Cookie(sessionCookieName)
|
||
|
|
if err != nil || c.Value == "" {
|
||
|
|
return nil, nil, nil
|
||
|
|
}
|
||
|
|
sess, err := a.DB.GetSession(c.Value)
|
||
|
|
if err != nil || sess == nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
if time.Now().After(sess.ExpiresAt) {
|
||
|
|
_ = a.DB.DeleteSession(sess.Token)
|
||
|
|
return nil, nil, nil
|
||
|
|
}
|
||
|
|
user, err := a.DB.GetAdminUserByID(sess.UserID)
|
||
|
|
if err != nil || user == nil {
|
||
|
|
return nil, nil, err
|
||
|
|
}
|
||
|
|
return sess, user, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func userFromContext(r *http.Request) *db.AdminUser {
|
||
|
|
u, _ := r.Context().Value(ctxUserKey).(*db.AdminUser)
|
||
|
|
return u
|
||
|
|
}
|
||
|
|
|
||
|
|
// requireAuth gates every admin route behind a valid, fully-authenticated session:
|
||
|
|
// logged in, second factor satisfied if one is enabled, and not stuck in the forced
|
||
|
|
// first-login credential change. Unauthenticated/incomplete requests are redirected
|
||
|
|
// to the right step of the login flow rather than shown an error.
|
||
|
|
func (a *App) requireAuth(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
sess, user, err := a.currentSession(r)
|
||
|
|
if err != nil {
|
||
|
|
a.Logger.Error("session lookup: %v", err)
|
||
|
|
}
|
||
|
|
if sess == nil || user == nil {
|
||
|
|
http.Redirect(w, r, Prefix+"/login?next="+r.URL.Path, http.StatusFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
needsMFA := user.TOTPEnabled
|
||
|
|
if !needsMFA {
|
||
|
|
if n, _ := a.DB.CountWebAuthnCredentials(user.ID); n > 0 {
|
||
|
|
needsMFA = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if needsMFA && !sess.MFAVerified {
|
||
|
|
http.Redirect(w, r, Prefix+"/login/mfa", http.StatusFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if user.MustChangePassword && r.URL.Path != Prefix+"/first-login" {
|
||
|
|
http.Redirect(w, r, Prefix+"/first-login", http.StatusFound)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
scope, err := a.buildAccessScope(user)
|
||
|
|
if err != nil {
|
||
|
|
a.Logger.Error("build access scope: %v", err)
|
||
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.WithValue(r.Context(), ctxUserKey, user)
|
||
|
|
ctx = context.WithValue(ctx, ctxScopeKey, scope)
|
||
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||
|
|
})
|
||
|
|
}
|