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).
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user