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).
321 lines
7.9 KiB
Go
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
|
|
}
|