From d0054ea3946ba7365be241bfb2f93e3d105a7e7b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 3 Jul 2026 18:32:25 -0400 Subject: [PATCH] Split out auth to server_auth.go --- server/server.go | 124 ------------------------------------ server/server_auth.go | 107 +++++++++++++++++++++++++++++++ server/server_middleware.go | 29 +++++++++ 3 files changed, 136 insertions(+), 124 deletions(-) create mode 100644 server/server_auth.go diff --git a/server/server.go b/server/server.go index cc197fbc..5934c485 100644 --- a/server/server.go +++ b/server/server.go @@ -2064,130 +2064,6 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc { } } -func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc { - return s.authorizeTopic(next, user.PermissionWrite) -} - -func (s *Server) authorizeTopicRead(next handleFunc) handleFunc { - return s.authorizeTopic(next, user.PermissionRead) -} - -func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc { - return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.userManager == nil { - return next(w, r, v) - } - topics, _, err := s.topicsFromPath(v, r.URL.Path) - if err != nil { - return err - } - u := v.User() - for _, t := range topics { - if err := s.userManager.Authorize(u, t.ID, perm); err != nil { - logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) - return errHTTPForbidden.With(t) - } - } - return next(w, r, v) - } -} - -// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user -// if it is set. -// -// - If auth-file is not configured, immediately return an IP-based visitor -// - If the header is not set or not supported (anything non-Basic and non-Bearer), -// an IP-based visitor is returned -// - If the header is set, authenticate will be called to check the username/password (Basic auth), -// or the token (Bearer auth), and read the user from the database -// -// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so -// that subsequent logging calls still have a visitor context. -func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { - // Read the "Authorization" header value and exit out early if it's not set - ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) - vip := s.visitor(ip, nil) - if s.userManager == nil { - return vip, nil - } - header, err := readAuthHeader(r) - if err != nil { - return vip, err - } else if !supportedAuthHeader(header) { - return vip, nil - } - // If we're trying to auth, check the rate limiter first - if !vip.AuthAllowed() { - return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! - } - u, err := s.authenticate(r, header) - if err != nil { - vip.AuthFailed() - logr(r).Err(err).Debug("Authentication failed") - return vip, errHTTPUnauthorized // Always return visitor, even when error occurs! - } - // Authentication with user was successful - return s.visitor(ip, u), nil -} - -// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...). -// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to -// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth -// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)). -func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) { - if strings.HasPrefix(header, "Bearer") { - return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer"))) - } - return s.authenticateBasicAuth(r, header) -} - -// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header, -// or from the ?auth... query parameter -func readAuthHeader(r *http.Request) (string, error) { - value := strings.TrimSpace(r.Header.Get("Authorization")) - queryParam := readQueryParam(r, "authorization", "auth") - if queryParam != "" { - a, err := base64.RawURLEncoding.DecodeString(queryParam) - if err != nil { - return "", err - } - value = strings.TrimSpace(string(a)) - } - return value, nil -} - -// supportedAuthHeader returns true only if the Authorization header value starts -// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither -// are things like "WebPush", or "vapid" (see #629). -func supportedAuthHeader(value string) bool { - value = strings.ToLower(value) - return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ") -} - -func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) { - r.Header.Set("Authorization", value) - username, password, ok := r.BasicAuth() - if !ok { - return nil, errors.New("invalid basic auth") - } else if username == "" { - return s.authenticateBearerAuth(r, password) // Treat password as token - } - return s.userManager.Authenticate(username, password) -} - -func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) { - u, err := s.userManager.AuthenticateToken(token) - if err != nil { - return nil, err - } - ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) - go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{ - LastAccess: time.Now(), - LastOrigin: ip, - }) - return u, nil -} - func (s *Server) visitor(ip netip.Addr, user *user.User) *visitor { s.mu.Lock() defer s.mu.Unlock() diff --git a/server/server_auth.go b/server/server_auth.go new file mode 100644 index 00000000..3593b330 --- /dev/null +++ b/server/server_auth.go @@ -0,0 +1,107 @@ +package server + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + "time" + + "heckel.io/ntfy/v2/user" +) + +// maybeAuthenticate reads the "Authorization" header and will try to authenticate the user +// if it is set. +// +// - If auth-file is not configured, immediately return an IP-based visitor +// - If the header is not set or not supported (anything non-Basic and non-Bearer), +// an IP-based visitor is returned +// - If the header is set, authenticate will be called to check the username/password (Basic auth), +// or the token (Bearer auth), and read the user from the database +// +// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so +// that subsequent logging calls still have a visitor context. +func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { + // Read the "Authorization" header value and exit out early if it's not set + ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) + vip := s.visitor(ip, nil) + if s.userManager == nil { + return vip, nil + } + header, err := readAuthHeader(r) + if err != nil { + return vip, err + } else if !supportedAuthHeader(header) { + return vip, nil + } + // If we're trying to auth, check the rate limiter first + if !vip.AuthAllowed() { + return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! + } + u, err := s.authenticate(r, header) + if err != nil { + vip.AuthFailed() + logr(r).Err(err).Debug("Authentication failed") + return vip, errHTTPUnauthorized // Always return visitor, even when error occurs! + } + // Authentication with user was successful + return s.visitor(ip, u), nil +} + +// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...). +// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to +// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth +// query param is effectively doubly base64 encoded. Its format is base64(Basic base64(user:pass)). +func (s *Server) authenticate(r *http.Request, header string) (user *user.User, err error) { + if strings.HasPrefix(header, "Bearer") { + return s.authenticateBearerAuth(r, strings.TrimSpace(strings.TrimPrefix(header, "Bearer"))) + } + return s.authenticateBasicAuth(r, header) +} + +// readAuthHeader reads the raw value of the Authorization header, either from the actual HTTP header, +// or from the ?auth... query parameter +func readAuthHeader(r *http.Request) (string, error) { + value := strings.TrimSpace(r.Header.Get("Authorization")) + queryParam := readQueryParam(r, "authorization", "auth") + if queryParam != "" { + a, err := base64.RawURLEncoding.DecodeString(queryParam) + if err != nil { + return "", err + } + value = strings.TrimSpace(string(a)) + } + return value, nil +} + +// supportedAuthHeader returns true only if the Authorization header value starts +// with "Basic" or "Bearer". In particular, an empty value is not supported, and neither +// are things like "WebPush", or "vapid" (see #629). +func supportedAuthHeader(value string) bool { + value = strings.ToLower(value) + return strings.HasPrefix(value, "basic ") || strings.HasPrefix(value, "bearer ") +} + +func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) { + r.Header.Set("Authorization", value) + username, password, ok := r.BasicAuth() + if !ok { + return nil, errors.New("invalid basic auth") + } else if username == "" { + return s.authenticateBearerAuth(r, password) // Treat password as token + } + return s.userManager.Authenticate(username, password) +} + +func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.User, error) { + u, err := s.userManager.AuthenticateToken(token) + if err != nil { + return nil, err + } + ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) + go s.userManager.EnqueueTokenUpdate(token, &user.TokenUpdate{ + LastAccess: time.Now(), + LastOrigin: ip, + }) + return u, nil +} diff --git a/server/server_middleware.go b/server/server_middleware.go index cc77b6df..3e65a66a 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -3,6 +3,7 @@ package server import ( "net/http" + "heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/util" ) @@ -139,3 +140,31 @@ func (s *Server) withAccountSync(next handleFunc) handleFunc { return err } } + +func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc { + return s.authorizeTopic(next, user.PermissionWrite) +} + +func (s *Server) authorizeTopicRead(next handleFunc) handleFunc { + return s.authorizeTopic(next, user.PermissionRead) +} + +func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.userManager == nil { + return next(w, r, v) + } + topics, _, err := s.topicsFromPath(v, r.URL.Path) + if err != nil { + return err + } + u := v.User() + for _, t := range topics { + if err := s.userManager.Authorize(u, t.ID, perm); err != nil { + logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) + return errHTTPForbidden.With(t) + } + } + return next(w, r, v) + } +}