Auth logout URL, auth proxy
This commit is contained in:
32
cmd/serve.go
32
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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".
|
||||
#
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = () => {
|
||||
</ListItemIcon>
|
||||
{t("action_bar_profile_settings")}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<Logout fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t("action_bar_profile_logout")}
|
||||
</MenuItem>
|
||||
{showLogout && (
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<Logout fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{t("action_bar_profile_logout")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</PopupMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user