Files
Squid a79cb61104 Add Migadu domain admin with shared lists, compaction, and safer apply.
Build the Go API and React UI for managing domain denylist/allowlist/recipient
deny and spam settings, plus shared lists that import/apply across domains with
wildcard compaction, entry verification, and Migadu-friendly list encoding
(including per-domain recipient filtering and rejection bisect on apply).
2026-07-26 21:50:26 +02:00

321 lines
7.9 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/squid/MigaduAdmin/internal/config"
"github.com/squid/MigaduAdmin/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
SessionCookieName = "migaduadmin_session"
SessionTTL = 7 * 24 * time.Hour
bcryptCost = 12
)
type contextKey string
const userContextKey contextKey = "user"
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrClosedReg = errors.New("registration is closed")
)
type Service struct {
store *store.Store
config *config.Config
}
func NewService(st *store.Store, cfg *config.Config) *Service {
return &Service{store: st, config: cfg}
}
func (s *Service) HashPassword(password string) (string, error) {
if len(password) < 8 {
return "", fmt.Errorf("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (s *Service) CheckPassword(hash, password string) bool {
if hash == "" || password == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
func (s *Service) RegistrationAllowed(ctx context.Context) (bool, error) {
count, err := s.store.CountUsers(ctx)
if err != nil {
return false, err
}
if count == 0 {
return true, nil
}
return s.config.RegistrationOpen, nil
}
func (s *Service) Register(ctx context.Context, email, displayName, password string) (*store.User, error) {
allowed, err := s.RegistrationAllowed(ctx)
if err != nil {
return nil, err
}
if !allowed {
return nil, ErrClosedReg
}
count, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
}
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
return s.store.CreateUser(ctx, email, displayName, hash, role)
}
func (s *Service) CreateUserAsAdmin(ctx context.Context, email, displayName, password string, role store.Role) (*store.User, error) {
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
if role == "" {
role = store.RoleUser
}
return s.store.CreateUser(ctx, email, displayName, hash, role)
}
func (s *Service) AuthenticateLocal(ctx context.Context, email, password string) (*store.User, error) {
user, err := s.store.GetUserByEmail(ctx, email)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
if !s.CheckPassword(user.PasswordHash, password) {
return nil, ErrUnauthorized
}
return user, nil
}
func (s *Service) CreateSession(ctx context.Context, w http.ResponseWriter, userID int64) error {
_ = s.store.DeleteExpiredSessions(ctx)
token, err := randomToken(32)
if err != nil {
return err
}
expires := time.Now().UTC().Add(SessionTTL)
if err := s.store.CreateSession(ctx, token, userID, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.CookieSecure,
Expires: expires,
MaxAge: int(SessionTTL.Seconds()),
})
return nil
}
func (s *Service) ClearSession(ctx context.Context, w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" {
_ = s.store.DeleteSession(ctx, cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.config.CookieSecure,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*store.User, error) {
if s.config.AuthMode == config.AuthModePangolin || s.config.AuthMode == config.AuthModeBoth {
if user, err := s.userFromPangolin(ctx, r); err == nil {
return user, nil
} else if !errors.Is(err, ErrUnauthorized) {
return nil, err
}
}
if s.config.AuthMode == config.AuthModeLocal || s.config.AuthMode == config.AuthModeBoth {
return s.userFromSession(ctx, r)
}
return nil, ErrUnauthorized
}
func (s *Service) userFromSession(ctx context.Context, r *http.Request) (*store.User, error) {
cookie, err := r.Cookie(SessionCookieName)
if err != nil || cookie.Value == "" {
return nil, ErrUnauthorized
}
session, err := s.store.GetSession(ctx, cookie.Value)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
if time.Now().UTC().After(session.ExpiresAt) {
_ = s.store.DeleteSession(ctx, session.ID)
return nil, ErrUnauthorized
}
user, err := s.store.GetUserByID(ctx, session.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, ErrUnauthorized
}
return nil, err
}
return user, nil
}
func (s *Service) userFromPangolin(ctx context.Context, r *http.Request) (*store.User, error) {
if !s.isTrustedPeer(r) {
return nil, ErrUnauthorized
}
email := strings.TrimSpace(r.Header.Get("Remote-Email"))
if email == "" {
return nil, ErrUnauthorized
}
displayName := strings.TrimSpace(r.Header.Get("Remote-Name"))
if displayName == "" {
displayName = strings.TrimSpace(r.Header.Get("Remote-User"))
}
user, err := s.store.GetUserByEmail(ctx, email)
if err == nil {
return user, nil
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
count, err := s.store.CountUsers(ctx)
if err != nil {
return nil, err
}
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
} else if !s.config.SSOAutoProvision {
return nil, ErrUnauthorized
}
return s.store.CreateUser(ctx, email, displayName, "", role)
}
func (s *Service) isTrustedPeer(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return s.config.IsTrustedProxy(ip)
}
// StripUntrustedIdentityHeaders removes Pangolin identity headers unless the peer is trusted.
func (s *Service) StripUntrustedIdentityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.isTrustedPeer(r) {
headers := r.Header.Clone()
headers.Del("Remote-Email")
headers.Del("Remote-User")
headers.Del("Remote-Name")
headers.Del("Remote-Role")
r = r.Clone(r.Context())
r.Header = headers
}
next.ServeHTTP(w, r)
})
}
func (s *Service) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := s.UserFromRequest(r.Context(), r)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Service) RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := UserFromContext(r.Context())
if user == nil || user.Role != store.RoleAdmin {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func UserFromContext(ctx context.Context) *store.User {
user, _ := ctx.Value(userContextKey).(*store.User)
return user
}
func ConstantTimeEquals(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
func randomToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func RandomSecret(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}