diff --git a/cmd/serve.go b/cmd/serve.go index eb30bbd6..f7034ca1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -96,6 +96,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-user-header", Aliases: []string{"auth_user_header"}, EnvVars: []string{"NTFY_AUTH_USER_HEADER"}, Value: "", Usage: "if set (along with behind-proxy and auth-file), trust this header to contain the authenticated username (e.g. X-Forwarded-User, Remote-User)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-logout-url", Aliases: []string{"auth_logout_url"}, EnvVars: []string{"NTFY_AUTH_LOGOUT_URL"}, Value: "", Usage: "URL to redirect to when logging out in proxy auth mode (e.g. https://auth.example.com/logout)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "billing-contact", Aliases: []string{"billing_contact"}, EnvVars: []string{"NTFY_BILLING_CONTACT"}, Value: "", Usage: "e-mail or website to display in upgrade dialog (only if payments are enabled)"}), @@ -208,6 +209,7 @@ func execServe(c *cli.Context) error { proxyForwardedHeader := c.String("proxy-forwarded-header") proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") authUserHeader := c.String("auth-user-header") + authLogoutURL := c.String("auth-logout-url") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") billingContact := c.String("billing-contact") @@ -315,7 +317,8 @@ func execServe(c *cli.Context) error { } else if u.Path != "" { return fmt.Errorf("if set, base-url must not have a path (%s), as hosting ntfy on a sub-path is not supported, e.g. https://ntfy.mydomain.com", u.Path) } - } else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") { + } + if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") { return errors.New("if set, upstream-base-url must start with http:// or https://") } else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") { return errors.New("if set, upstream-base-url must not end with a slash (/)") @@ -340,7 +343,8 @@ func execServe(c *cli.Context) error { if messageSizeLimit > 5*1024*1024 { return errors.New("message-size-limit cannot be higher than 5M") } - } else if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") { + } + if !server.WebPushAvailable && (webPushPrivateKey != "" || webPushPublicKey != "" || webPushFile != "") { return errors.New("cannot enable WebPush, support is not available in this build (nowebpush)") } else if webPushExpiryWarningDuration > 0 && webPushExpiryWarningDuration > webPushExpiryDuration { return errors.New("web push expiry warning duration cannot be higher than web push expiry duration") @@ -350,6 +354,10 @@ func execServe(c *cli.Context) error { return errors.New("auth-user-header requires behind-proxy to be set") } else if authUserHeader != "" && authFile == "" { return errors.New("auth-user-header requires auth-file to be set") + } else if authUserHeader != "" && enableLogin { + return errors.New("auth-user-header cannot be used with enable-login") + } else if authUserHeader != "" && enableSignup { + return errors.New("auth-user-header cannot be used with enable-signup") } else if visitorPrefixBitsIPv4 < 1 || visitorPrefixBitsIPv4 > 32 { return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { @@ -418,6 +426,15 @@ func execServe(c *cli.Context) error { payments.Setup(stripeSecretKey) } + // Parse Twilio call format template + var twilioCallFormatTemplate *template.Template + if twilioCallFormat != "" { + twilioCallFormatTemplate, err = template.New("").Parse(twilioCallFormat) + if err != nil { + return fmt.Errorf("failed to parse twilio-call-format template: %w", err) + } + } + // Add default forbidden topics disallowedTopics = append(disallowedTopics, server.DefaultDisallowedTopics...) @@ -443,6 +460,8 @@ func execServe(c *cli.Context) error { conf.AuthUsers = authUsers conf.AuthAccess = authAccess conf.AuthTokens = authTokens + conf.AuthUserHeader = authUserHeader + conf.AuthLogoutURL = authLogoutURL conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit @@ -465,13 +484,7 @@ func execServe(c *cli.Context) error { conf.TwilioAuthToken = twilioAuthToken conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService - if twilioCallFormat != "" { - tmpl, err := template.New("twiml").Parse(twilioCallFormat) - if err != nil { - return fmt.Errorf("failed to parse twilio-call-format template: %w", err) - } - conf.TwilioCallFormat = tmpl - } + conf.TwilioCallFormat = twilioCallFormatTemplate conf.MessageSizeLimit = int(messageSizeLimit) conf.MessageDelayMax = messageDelayLimit conf.TotalTopicLimit = totalTopicLimit @@ -490,7 +503,6 @@ func execServe(c *cli.Context) error { conf.BehindProxy = behindProxy conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyTrustedPrefixes = trustedProxyPrefixes - conf.AuthUserHeader = authUserHeader conf.StripeSecretKey = stripeSecretKey conf.StripeWebhookKey = stripeWebhookKey conf.BillingContact = billingContact diff --git a/server/config.go b/server/config.go index 4410c1bc..8824fe91 100644 --- a/server/config.go +++ b/server/config.go @@ -167,6 +167,7 @@ type Config struct { ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true AuthUserHeader string // Header to read the authenticated user from, if BehindProxy is true (e.g. X-Forwarded-User, Remote-User) + AuthLogoutURL string // URL to redirect to when logging out in proxy auth mode (e.g. https://auth.example.com/logout) StripeSecretKey string StripeWebhookKey string StripePriceCacheDuration time.Duration @@ -265,6 +266,7 @@ func NewConfig() *Config { BehindProxy: false, // If true, the server will trust the proxy client IP header to determine the client IP address ProxyForwardedHeader: "X-Forwarded-For", // Default header for reverse proxy client IPs AuthUserHeader: "", // Header to read the authenticated user from (requires behind-proxy and auth-file) + AuthLogoutURL: "", // URL to redirect to when logging out in proxy auth mode StripeSecretKey: "", StripeWebhookKey: "", StripePriceCacheDuration: DefaultStripePriceCacheDuration, diff --git a/server/server.go b/server/server.go index 9dcae651..db690479 100644 --- a/server/server.go +++ b/server/server.go @@ -603,13 +603,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor return s.writeJSON(w, response) } -func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { +func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, v *visitor) error { w.Header().Set("Cache-Control", "no-cache") - return s.writeJSON(w, s.configResponse()) + return s.writeJSON(w, s.configResponse(v)) } -func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error { - b, err := json.MarshalIndent(s.configResponse(), "", " ") +func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, v *visitor) error { + b, err := json.MarshalIndent(s.configResponse(v), "", " ") if err != nil { return err } @@ -619,7 +619,15 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi return err } -func (s *Server) configResponse() *apiConfigResponse { +func (s *Server) configResponse(v *visitor) *apiConfigResponse { + authMode := "" + username := "" + if s.config.AuthUserHeader != "" { + authMode = "proxy" + if v != nil && v.User() != nil { + username = v.User().Name + } + } return &apiConfigResponse{ BaseURL: "", // Will translate to window.location.origin AppRoot: s.config.WebRoot, @@ -635,6 +643,9 @@ func (s *Server) configResponse() *apiConfigResponse { WebPushPublicKey: s.config.WebPushPublicKey, DisallowedTopics: s.config.DisallowedTopics, ConfigHash: s.config.Hash(), + AuthMode: authMode, + AuthLogoutURL: s.config.AuthLogoutURL, + Username: username, } } diff --git a/server/server.yml b/server/server.yml index 7356cb0e..484327fe 100644 --- a/server/server.yml +++ b/server/server.yml @@ -139,6 +139,12 @@ # # auth-user-header: +# If auth-user-header is set, this is the URL to redirect users to when they click logout. +# This is typically the logout URL of your authentication proxy (e.g. Authelia, Authentik). +# If not set, the logout button will be hidden in the web UI when using proxy auth. +# +# auth-logout-url: + # If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments # are "attachment-cache-dir" and "base-url". # diff --git a/server/server_test.go b/server/server_test.go index 3ce6b33f..3cb58e97 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2428,7 +2428,7 @@ func TestServer_AuthUserHeader_UserNotFound(t *testing.T) { require.Equal(t, errHTTPUnauthorized, err) } -func TestServer_AuthUserHeader_NoHeader_FallbackToStandardAuth(t *testing.T) { +func TestServer_AuthUserHeader_NoHeader_ReturnsUnauthorized(t *testing.T) { c := newTestConfigWithAuthFile(t) c.BehindProxy = true c.AuthUserHeader = "X-Forwarded-User" @@ -2436,28 +2436,27 @@ func TestServer_AuthUserHeader_NoHeader_FallbackToStandardAuth(t *testing.T) { require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false)) - // No X-Forwarded-User header, but with Authorization header + // No X-Forwarded-User header, even with Authorization header -> unauthorized + // When auth-user-header is configured, the header MUST be present r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) r.RemoteAddr = "1.2.3.4:1234" r.Header.Set("Authorization", util.BasicAuth("phil", "phil")) - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.NotNil(t, v.User()) - require.Equal(t, "phil", v.User().Name) + _, err := s.maybeAuthenticate(r) + require.Equal(t, errHTTPUnauthorized, err) } -func TestServer_AuthUserHeader_NoHeader_AnonymousAllowed(t *testing.T) { +func TestServer_AuthUserHeader_NoHeader_NoAuthReturnsUnauthorized(t *testing.T) { c := newTestConfigWithAuthFile(t) c.BehindProxy = true c.AuthUserHeader = "X-Forwarded-User" s := newTestServer(t, c) - // No X-Forwarded-User header and no Authorization header -> anonymous + // No X-Forwarded-User header and no Authorization header -> unauthorized + // When auth-user-header is configured, the header MUST be present r, _ := http.NewRequest("GET", "/mytopic/json?poll=1", nil) r.RemoteAddr = "1.2.3.4:1234" - v, err := s.maybeAuthenticate(r) - require.Nil(t, err) - require.Nil(t, v.User()) + _, err := s.maybeAuthenticate(r) + require.Equal(t, errHTTPUnauthorized, err) } func TestServer_AuthUserHeader_NotBehindProxy(t *testing.T) { diff --git a/server/types.go b/server/types.go index fae44e5b..4b2e3b11 100644 --- a/server/types.go +++ b/server/types.go @@ -483,6 +483,9 @@ type apiConfigResponse struct { WebPushPublicKey string `json:"web_push_public_key"` DisallowedTopics []string `json:"disallowed_topics"` ConfigHash string `json:"config_hash"` + AuthMode string `json:"auth_mode,omitempty"` // "proxy" if auth-user-header is set, empty otherwise + AuthLogoutURL string `json:"auth_logout_url,omitempty"` // URL to redirect to on logout (only for proxy auth) + Username string `json:"username,omitempty"` // Authenticated username (for proxy auth) } type apiAccountBillingPrices struct { diff --git a/web/public/config.js b/web/public/config.js index 62f49ed4..0e2ab8a8 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -20,4 +20,7 @@ var config = { web_push_public_key: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], config_hash: "dev", // Placeholder for development; actual value is generated server-side + auth_mode: "", // "proxy" if auth-user-header is set, empty otherwise + auth_logout_url: "", // URL to redirect to on logout (only for proxy auth) + username: "", // Authenticated username (for proxy auth) }; diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index d9380438..f3c48948 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -16,6 +16,7 @@ import { withBasicAuth, withBearerAuth, } from "./utils"; +import config from "./config"; import session from "./Session"; import subscriptionManager from "./SubscriptionManager"; import prefs from "./Prefs"; @@ -341,7 +342,12 @@ class AccountApi { async sync() { try { - if (!session.token()) { + // For proxy auth, store the username from config if not already in session + if (config.auth_mode === "proxy" && config.username && !session.exists()) { + console.log(`[AccountApi] Proxy auth: storing session for user ${config.username}`); + await session.store(config.username, ""); // Empty token for proxy auth + } + if (!session.exists()) { return null; } console.log(`[AccountApi] Syncing account`); diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 7464150c..7502a851 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -3,6 +3,10 @@ import Dexie from "dexie"; /** * Manages the logged-in user's session and access token. * The session replica is stored in IndexedDB so that the service worker can access it. + * + * For proxy authentication (when config.auth_mode === "proxy"), the token will be empty + * since authentication is handled by the proxy. In this case, store(username, "") is called + * with an empty token, and exists() returns true based on the username alone. */ class Session { constructor() { @@ -53,7 +57,7 @@ class Session { } exists() { - return this.username() && this.token(); + return !!this.username(); } username() { diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index 1f41aac0..54cd9f21 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -139,6 +139,17 @@ const ProfileIcon = () => { }; const handleLogout = async () => { + // For proxy auth, redirect to the logout URL if configured + if (config.auth_mode === "proxy") { + if (config.auth_logout_url) { + await db().delete(); + localStorage.removeItem("user"); + localStorage.removeItem("token"); + window.location.href = config.auth_logout_url; + } + return; + } + // Standard logout try { await accountApi.logout(); await db().delete(); @@ -147,6 +158,9 @@ const ProfileIcon = () => { } }; + // Determine if logout button should be shown + const showLogout = config.auth_mode !== "proxy" || config.auth_logout_url; + return ( <> {session.exists() && ( @@ -178,12 +192,14 @@ const ProfileIcon = () => { {t("action_bar_profile_settings")} - - - - - {t("action_bar_profile_logout")} - + {showLogout && ( + + + + + {t("action_bar_profile_logout")} + + )} ); diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index 5c1af249..15006e3c 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Typography, TextField, Button, Box, IconButton, InputAdornment } from "@mui/material"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { NavLink } from "react-router-dom"; @@ -18,6 +18,13 @@ const Login = () => { const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); + // Redirect to app if using proxy authentication + useEffect(() => { + if (config.auth_mode === "proxy") { + window.location.href = routes.app; + } + }, []); + const handleSubmit = async (event) => { event.preventDefault(); const user = { username, password };