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 }