diff --git a/server/server.go b/server/server.go index b54a9d87..88be069f 100644 --- a/server/server.go +++ b/server/server.go @@ -862,7 +862,7 @@ func parseSince(r *http.Request, poll bool) (sinceTime, error) { func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error { w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth + w.Header().Set("Access-Control-Allow-Headers", "Authorization") // CORS, allow auth via JS return nil } @@ -1091,7 +1091,7 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc { return err } var user *auth.User // may stay nil if no auth header! - username, password, ok := r.BasicAuth() + username, password, ok := extractUserPass(r) if ok { if user, err = s.auth.Authenticate(username, password); err != nil { log.Printf("authentication failed: %s", err.Error()) @@ -1108,6 +1108,27 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc { } } +// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...), +// or from 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 double base64 encoded. Its format is base64(Basic base64(user:pass)). +func extractUserPass(r *http.Request) (username string, password string, ok bool) { + username, password, ok = r.BasicAuth() + if ok { + return + } + authParam := readQueryParam(r, "authorization", "auth") + if authParam != "" { + a, err := base64.RawURLEncoding.DecodeString(authParam) + if err != nil { + return + } + r.Header.Set("Authorization", string(a)) + return r.BasicAuth() + } + return +} + // visitor creates or retrieves a rate.Limiter for the given visitor. // This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT). func (s *Server) visitor(r *http.Request) *visitor { diff --git a/server/server_test.go b/server/server_test.go index 614cd5c9..dcc23650 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -657,6 +657,25 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) { require.Equal(t, 403, response.Code) // Anonymous read not allowed } +func TestServer_Auth_ViaQuery(t *testing.T) { + c := newTestConfig(t) + c.AuthFile = filepath.Join(t.TempDir(), "user.db") + c.AuthDefaultRead = false + c.AuthDefaultWrite = false + s := newTestServer(t, c) + + manager := s.auth.(auth.Manager) + require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin)) + + u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass")))) + response := request(t, s, "GET", u, "", nil) + require.Equal(t, 200, response.Code) + + u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG")))) + response = request(t, s, "GET", u, "", nil) + require.Equal(t, 401, response.Code) +} + /* func TestServer_Curl_Publish_Poll(t *testing.T) { s, port := test.StartServer(t) diff --git a/server/util.go b/server/util.go index 08832dcf..7c596344 100644 --- a/server/util.go +++ b/server/util.go @@ -14,12 +14,24 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { } func readParam(r *http.Request, names ...string) string { + value := readHeaderParam(r, names...) + if value != "" { + return value + } + return readQueryParam(r, names...) +} + +func readHeaderParam(r *http.Request, names ...string) string { for _, name := range names { value := r.Header.Get(name) if value != "" { return strings.TrimSpace(value) } } + return "" +} + +func readQueryParam(r *http.Request, names ...string) string { for _, name := range names { value := r.URL.Query().Get(strings.ToLower(name)) if value != "" { diff --git a/web/src/app/Api.js b/web/src/app/Api.js index dc43a296..ae21ad3c 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,31 +1,32 @@ -import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth} from "./utils"; +import {topicUrlJsonPoll, fetchLinesIterator, topicUrl, topicUrlAuth, maybeWithBasicAuth} from "./utils"; class Api { - async poll(baseUrl, topic) { + async poll(baseUrl, topic, user) { const url = topicUrlJsonPoll(baseUrl, topic); const messages = []; + const headers = maybeWithBasicAuth({}, user); console.log(`[Api] Polling ${url}`); - for await (let line of fetchLinesIterator(url)) { + for await (let line of fetchLinesIterator(url, headers)) { messages.push(JSON.parse(line)); } return messages; } - async publish(baseUrl, topic, message) { + async publish(baseUrl, topic, user, message) { const url = topicUrl(baseUrl, topic); console.log(`[Api] Publishing message to ${url}`); await fetch(url, { method: 'PUT', - body: message + body: message, + headers: maybeWithBasicAuth({}, user) }); } async auth(baseUrl, topic, user) { const url = topicUrlAuth(baseUrl, topic); console.log(`[Api] Checking auth for ${url}`); - const headers = this.maybeAddAuthorization({}, user); const response = await fetch(url, { - headers: headers + headers: maybeWithBasicAuth({}, user) }); if (response.status >= 200 && response.status <= 299) { return true; @@ -36,14 +37,6 @@ class Api { } throw new Error(`Unexpected server response ${response.status}`); } - - maybeAddAuthorization(headers, user) { - if (user) { - const encoded = new Buffer(`${user.username}:${user.password}`).toString('base64'); - headers['Authorization'] = `Basic ${encoded}`; - } - return headers; - } } const api = new Api(); diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 914fcf45..fdf5c99f 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,14 +1,15 @@ -import {shortTopicUrl, topicUrlWs, topicUrlWsWithSince} from "./utils"; +import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; const retryBackoffSeconds = [5, 10, 15, 20, 30, 45]; class Connection { - constructor(subscriptionId, baseUrl, topic, since, onNotification) { + constructor(subscriptionId, baseUrl, topic, user, since, onNotification) { this.subscriptionId = subscriptionId; this.baseUrl = baseUrl; this.topic = topic; + this.user = user; this.since = since; - this.shortUrl = shortTopicUrl(baseUrl, topic); + this.shortUrl = topicShortUrl(baseUrl, topic); this.onNotification = onNotification; this.ws = null; this.retryCount = 0; @@ -18,10 +19,10 @@ class Connection { start() { // Don't fetch old messages; we do that as a poll() when adding a subscription; // we don't want to re-trigger the main view re-render potentially hundreds of times. - const wsUrl = (this.since === 0) - ? topicUrlWs(this.baseUrl, this.topic) - : topicUrlWsWithSince(this.baseUrl, this.topic, this.since.toString()); + + const wsUrl = this.wsUrl(); console.log(`[Connection, ${this.shortUrl}] Opening connection to ${wsUrl}`); + this.ws = new WebSocket(wsUrl); this.ws.onopen = (event) => { console.log(`[Connection, ${this.shortUrl}] Connection established`, event); @@ -75,6 +76,19 @@ class Connection { this.retryTimeout = null; this.ws = null; } + + wsUrl() { + const params = []; + if (this.since > 0) { + params.push(`since=${this.since.toString()}`); + } + if (this.user !== null) { + const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password)); + params.push(`auth=${auth}`); + } + const wsUrl = topicUrlWs(this.baseUrl, this.topic); + return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; + } } export default Connection; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 374bba2b..67b41362 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -1,11 +1,11 @@ import Connection from "./Connection"; -export class ConnectionManager { +class ConnectionManager { constructor() { this.connections = new Map(); } - refresh(subscriptions, onNotification) { + refresh(subscriptions, users, onNotification) { console.log(`[ConnectionManager] Refreshing connections`); const subscriptionIds = subscriptions.ids(); const deletedIds = Array.from(this.connections.keys()).filter(id => !subscriptionIds.includes(id)); @@ -16,8 +16,9 @@ export class ConnectionManager { if (added) { const baseUrl = subscription.baseUrl; const topic = subscription.topic; + const user = users.get(baseUrl); const since = 0; - const connection = new Connection(id, baseUrl, topic, since, onNotification); + const connection = new Connection(id, baseUrl, topic, user, since, onNotification); this.connections.set(id, connection); console.log(`[ConnectionManager] Starting new connection ${id}`); connection.start(); diff --git a/web/src/app/Repository.js b/web/src/app/Repository.js index 541e651d..72ebb11d 100644 --- a/web/src/app/Repository.js +++ b/web/src/app/Repository.js @@ -1,7 +1,9 @@ import Subscription from "./Subscription"; import Subscriptions from "./Subscriptions"; +import Users from "./Users"; +import User from "./User"; -export class Repository { +class Repository { loadSubscriptions() { console.log(`[Repository] Loading subscriptions from localStorage`); const subscriptions = new Subscriptions(); @@ -10,8 +12,7 @@ export class Repository { return subscriptions; } try { - const serializedSubscriptions = JSON.parse(serialized); - serializedSubscriptions.forEach(s => { + JSON.parse(serialized).forEach(s => { const subscription = new Subscription(s.baseUrl, s.topic); subscription.addNotifications(s.notifications); subscriptions.add(subscription); @@ -39,26 +40,32 @@ export class Repository { loadUsers() { console.log(`[Repository] Loading users from localStorage`); + const users = new Users(); const serialized = localStorage.getItem('users'); if (serialized === null) { - return {}; + return users; } try { - return JSON.parse(serialized); + JSON.parse(serialized).forEach(u => { + users.add(new User(u.baseUrl, u.username, u.password)); + }); + return users; } catch (e) { console.log(`[Repository] Unable to deserialize users: ${e.message}`); - return {}; + return users; } } - saveUser(baseUrl, username, password) { + saveUsers(users) { console.log(`[Repository] Saving users to localStorage`); - const users = this.loadUsers(); - users[baseUrl] = { - username: username, - password: password - }; - localStorage.setItem('users', users); + const serialized = JSON.stringify(users.map(user => { + return { + baseUrl: user.baseUrl, + username: user.username, + password: user.password + } + })); + localStorage.setItem('users', serialized); } } diff --git a/web/src/app/Subscription.js b/web/src/app/Subscription.js index 8b19a18b..56b360d0 100644 --- a/web/src/app/Subscription.js +++ b/web/src/app/Subscription.js @@ -1,6 +1,6 @@ -import {shortTopicUrl, topicUrl} from './utils'; +import {topicShortUrl, topicUrl} from './utils'; -export default class Subscription { +class Subscription { constructor(baseUrl, topic) { this.id = topicUrl(baseUrl, topic); this.baseUrl = baseUrl; @@ -40,6 +40,8 @@ export default class Subscription { } shortUrl() { - return shortTopicUrl(this.baseUrl, this.topic); + return topicShortUrl(this.baseUrl, this.topic); } } + +export default Subscription; diff --git a/web/src/app/User.js b/web/src/app/User.js new file mode 100644 index 00000000..f92a83dc --- /dev/null +++ b/web/src/app/User.js @@ -0,0 +1,9 @@ +class User { + constructor(baseUrl, username, password) { + this.baseUrl = baseUrl; + this.username = username; + this.password = password; + } +} + +export default User; diff --git a/web/src/app/Users.js b/web/src/app/Users.js new file mode 100644 index 00000000..a795a23a --- /dev/null +++ b/web/src/app/Users.js @@ -0,0 +1,36 @@ +class Users { + constructor() { + this.users = new Map(); + } + + add(user) { + this.users.set(user.baseUrl, user); + return this; + } + + get(baseUrl) { + const user = this.users.get(baseUrl); + return (user) ? user : null; + } + + update(user) { + return this.add(user); + } + + remove(baseUrl) { + this.users.delete(baseUrl); + return this; + } + + map(cb) { + return Array.from(this.users.values()).map(cb); + } + + clone() { + const c = new Users(); + c.users = new Map(this.users); + return c; + } +} + +export default Users; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 40b9f568..04847f2a 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -1,15 +1,14 @@ -import { rawEmojis} from "./emojis"; +import {rawEmojis} from "./emojis"; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` .replaceAll("https://", "wss://") .replaceAll("http://", "ws://"); -export const topicUrlWsWithSince = (baseUrl, topic, since) => `${topicUrlWs(baseUrl, topic)}?since=${since}`; export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; +export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); -export const shortTopicUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); // Format emojis (see emoji.js) const emojis = {}; @@ -51,10 +50,35 @@ export const unmatchedTags = (tags) => { else return tags.filter(tag => !(tag in emojis)); } + +export const maybeWithBasicAuth = (headers, user) => { + if (user) { + headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`; + } + return headers; +} + +export const basicAuth = (username, password) => { + return `Basic ${encodeBase64(`${username}:${password}`)}`; +} + +export const encodeBase64 = (s) => { + return new Buffer(s).toString('base64'); +} + +export const encodeBase64Url = (s) => { + return encodeBase64(s) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); +} + // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch -export async function* fetchLinesIterator(fileURL) { +export async function* fetchLinesIterator(fileURL, headers) { const utf8Decoder = new TextDecoder('utf-8'); - const response = await fetch(fileURL); + const response = await fetch(fileURL, { + headers: headers + }); const reader = response.body.getReader(); let { value: chunk, done: readerDone } = await reader.read(); chunk = chunk ? utf8Decoder.decode(chunk) : ''; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 7ecce8e4..05806478 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -25,6 +25,7 @@ const ActionBar = (props) => { <Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>{title}</Typography> {props.selectedSubscription !== null && <IconSubscribeSettings subscription={props.selectedSubscription} + users={props.users} onClearAll={props.onClearAll} onUnsubscribe={props.onUnsubscribe} />} diff --git a/web/src/components/App.js b/web/src/components/App.js index 51097833..d7aea030 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -12,12 +12,14 @@ import connectionManager from "../app/ConnectionManager"; import Subscriptions from "../app/Subscriptions"; import Navigation from "./Navigation"; import ActionBar from "./ActionBar"; +import Users from "../app/Users"; const App = () => { console.log(`[App] Rendering main view`); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [subscriptions, setSubscriptions] = useState(new Subscriptions()); + const [users, setUsers] = useState(new Users()); const [selectedSubscription, setSelectedSubscription] = useState(null); const handleNotification = (subscriptionId, notification) => { setSubscriptions(prev => { @@ -25,11 +27,14 @@ const App = () => { return prev.update(newSubscription).clone(); }); }; - const handleSubscribeSubmit = (subscription) => { + const handleSubscribeSubmit = (subscription, user) => { console.log(`[App] New subscription: ${subscription.id}`); + if (user !== null) { + setUsers(prev => prev.add(user).clone()); + } setSubscriptions(prev => prev.add(subscription).clone()); setSelectedSubscription(subscription); - api.poll(subscription.baseUrl, subscription.topic) + api.poll(subscription.baseUrl, subscription.topic, user) .then(messages => { setSubscriptions(prev => { const newSubscription = prev.get(subscription.id).addNotifications(messages); @@ -61,12 +66,13 @@ const App = () => { }; useEffect(() => { setSubscriptions(repository.loadSubscriptions()); + setUsers(repository.loadUsers()); }, [/* initial render only */]); useEffect(() => { - connectionManager.refresh(subscriptions, handleNotification); + connectionManager.refresh(subscriptions, users, handleNotification); repository.saveSubscriptions(subscriptions); - }, [subscriptions]); - + repository.saveUsers(users); + }, [subscriptions, users]); return ( <ThemeProvider theme={theme}> <CssBaseline/> @@ -74,6 +80,7 @@ const App = () => { <CssBaseline/> <ActionBar selectedSubscription={selectedSubscription} + users={users} onClearAll={handleDeleteAllNotifications} onUnsubscribe={handleUnsubscribe} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} diff --git a/web/src/components/IconSubscribeSettings.js b/web/src/components/IconSubscribeSettings.js index a3f18779..c8a3603a 100644 --- a/web/src/components/IconSubscribeSettings.js +++ b/web/src/components/IconSubscribeSettings.js @@ -14,6 +14,7 @@ import api from "../app/Api"; const IconSubscribeSettings = (props) => { const [open, setOpen] = useState(false); const anchorRef = useRef(null); + const users = props.users; const handleToggle = () => { setOpen((prevOpen) => !prevOpen); @@ -39,7 +40,9 @@ const IconSubscribeSettings = (props) => { const handleSendTestMessage = () => { const baseUrl = props.subscription.baseUrl; const topic = props.subscription.topic; - api.publish(baseUrl, topic, `This is a test notification sent by the ntfy Web UI at ${new Date().toString()}.`); // FIXME result ignored + const user = users.get(baseUrl); // May be null + api.publish(baseUrl, topic, user, + `This is a test notification sent by the ntfy Web UI at ${new Date().toString()}.`); // FIXME result ignored setOpen(false); } diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 5d8b6e7e..2fc29d23 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -54,10 +54,15 @@ const Navigation = (props) => { Navigation.width = navWidth; const NavList = (props) => { + const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); - const handleSubscribeSubmit = (subscription) => { + const handleSubscribeReset = () => { setSubscribeDialogOpen(false); - props.onSubscribeSubmit(subscription); + setSubscribeDialogKey(prev => prev+1); + } + const handleSubscribeSubmit = (subscription, user) => { + handleSubscribeReset(); + props.onSubscribeSubmit(subscription, user); } return ( <> @@ -85,13 +90,15 @@ const NavList = (props) => { </ListItemButton> </List> <SubscribeDialog + key={subscribeDialogKey} // Resets dialog when canceled/closed open={subscribeDialogOpen} - onCancel={() => setSubscribeDialogOpen(false)} - onSubmit={handleSubscribeSubmit} + onCancel={handleSubscribeReset} + onSuccess={handleSubscribeSubmit} /> </> ); }; + const NavSubscriptionList = (props) => { const subscriptions = props.subscriptions; return ( diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 20b3b0fd..6e34151c 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -13,6 +13,7 @@ import theme from "./theme"; import api from "../app/Api"; import {topicUrl} from "../app/utils"; import useStyles from "./styles"; +import User from "../app/User"; const defaultBaseUrl = "http://127.0.0.1" //const defaultBaseUrl = "https://ntfy.sh" @@ -20,43 +21,50 @@ const defaultBaseUrl = "http://127.0.0.1" const SubscribeDialog = (props) => { const [baseUrl, setBaseUrl] = useState(defaultBaseUrl); // FIXME const [topic, setTopic] = useState(""); - const [user, setUser] = useState(null); const [showLoginPage, setShowLoginPage] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const handleCancel = () => { setTopic(''); props.onCancel(); } - const handleSubmit = async () => { - const success = await api.auth(baseUrl, topic, null); - if (!success) { - console.log(`[SubscribeDialog] Login required for ${topicUrl(baseUrl, topic)}`) - setShowLoginPage(true); - return; - } - const subscription = new Subscription(defaultBaseUrl, topic); - props.onSubmit(subscription); + const handleSuccess = (baseUrl, topic, user) => { + const subscription = new Subscription(baseUrl, topic); + props.onSuccess(subscription, user); setTopic(''); } return ( <Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}> {!showLoginPage && <SubscribePage + baseUrl={baseUrl} topic={topic} setTopic={setTopic} onCancel={handleCancel} - onSubmit={handleSubmit} + onNeedsLogin={() => setShowLoginPage(true)} + onSuccess={handleSuccess} />} {showLoginPage && <LoginPage baseUrl={baseUrl} topic={topic} onBack={() => setShowLoginPage(false)} - onSubmit={handleSubmit} + onSuccess={handleSuccess} />} </Dialog> ); }; const SubscribePage = (props) => { + const baseUrl = props.baseUrl; + const topic = props.topic; + const handleSubscribe = async () => { + const success = await api.auth(baseUrl, topic, null); + if (!success) { + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for anonymous user`); + props.onNeedsLogin(); + return; + } + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for anonymous user`); + props.onSuccess(baseUrl, topic, null); + }; return ( <> <DialogTitle>Subscribe to topic</DialogTitle> @@ -79,7 +87,7 @@ const SubscribePage = (props) => { </DialogContent> <DialogActions> <Button onClick={props.onCancel}>Cancel</Button> - <Button onClick={props.onSubmit} disabled={props.topic === ""}>Subscribe</Button> + <Button onClick={handleSubscribe} disabled={props.topic === ""}>Subscribe</Button> </DialogActions> </> ); @@ -93,14 +101,15 @@ const LoginPage = (props) => { const baseUrl = props.baseUrl; const topic = props.topic; const handleLogin = async () => { - const user = {username: username, password: password}; + const user = new User(baseUrl, username, password); const success = await api.auth(baseUrl, topic, user); if (!success) { console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); setErrorText(`User ${username} not authorized`); return; } - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} successful for user ${username}`); + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); + props.onSuccess(baseUrl, topic, user); }; return ( <>