Просмотр исходного кода

Redirect UI if unauthorized API response

binwiederhier 3 лет назад
Родитель
Сommit
3aac1b2715

+ 0 - 1
server/server.go

@@ -42,7 +42,6 @@ import (
 		expire tokens
 		auto-refresh tokens from UI
 		reserve topics
-		handle invalid session token
 		purge accounts that were not logged into in X
 		sync subscription display name
 		reset daily limits for users

+ 32 - 10
web/src/app/Api.js

@@ -126,7 +126,7 @@ class Api {
             headers: maybeWithBasicAuth({}, user)
         });
         if (response.status === 401 || response.status === 403) {
-            return false;
+            throw new UnauthorizedError();
         } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
@@ -144,7 +144,9 @@ class Api {
             method: "DELETE",
             headers: maybeWithBearerAuth({}, token)
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
     }
@@ -175,7 +177,9 @@ class Api {
         const response = await fetch(url, {
             headers: maybeWithBearerAuth({}, token)
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
         const account = await response.json();
@@ -190,7 +194,9 @@ class Api {
             method: "DELETE",
             headers: maybeWithBearerAuth({}, token)
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
     }
@@ -205,7 +211,9 @@ class Api {
                 password: password
             })
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
     }
@@ -219,7 +227,9 @@ class Api {
             headers: maybeWithBearerAuth({}, token),
             body: body
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
     }
@@ -233,7 +243,9 @@ class Api {
             headers: maybeWithBearerAuth({}, token),
             body: body
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
         const subscription = await response.json();
@@ -248,7 +260,9 @@ class Api {
             method: "DELETE",
             headers: maybeWithBearerAuth({}, token)
         });
-        if (response.status !== 200) {
+        if (response.status === 401 || response.status === 403) {
+            throw new UnauthorizedError();
+        } else if (response.status !== 200) {
             throw new Error(`Unexpected server response ${response.status}`);
         }
     }
@@ -256,13 +270,21 @@ class Api {
 
 export class UsernameTakenError extends Error {
     constructor(username) {
-        super();
+        super("Username taken");
         this.username = username;
     }
 }
 
 export class AccountCreateLimitReachedError extends Error {
-    // Nothing
+    constructor() {
+        super("Account creation limit reached");
+    }
+}
+
+export class UnauthorizedError extends Error {
+    constructor() {
+        super("Unauthorized");
+    }
 }
 
 const api = new Api();

+ 9 - 1
web/src/components/Account.js

@@ -16,7 +16,7 @@ import DialogTitle from "@mui/material/DialogTitle";
 import DialogContent from "@mui/material/DialogContent";
 import TextField from "@mui/material/TextField";
 import DialogActions from "@mui/material/DialogActions";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import routes from "./routes";
 import IconButton from "@mui/material/IconButton";
 import {useNavigate, useOutletContext} from "react-router-dom";
@@ -152,6 +152,10 @@ const ChangePassword = () => {
             console.debug(`[Account] Password changed`);
         } catch (e) {
             console.log(`[Account] Error changing password`, e);
+            if ((e instanceof UnauthorizedError)) {
+                session.reset();
+                window.location.href = routes.login;
+            }
             // TODO show error
         }
     };
@@ -238,6 +242,10 @@ const DeleteAccount = () => {
             window.location.href = routes.app;
         } catch (e) {
             console.log(`[Account] Error deleting account`, e);
+            if ((e instanceof UnauthorizedError)) {
+                session.reset();
+                window.location.href = routes.login;
+            }
             // TODO show error
         }
     };

+ 10 - 2
web/src/components/ActionBar.js

@@ -18,7 +18,7 @@ import MenuList from '@mui/material/MenuList';
 import MoreVertIcon from "@mui/icons-material/MoreVert";
 import NotificationsIcon from '@mui/icons-material/Notifications';
 import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import routes from "./routes";
 import subscriptionManager from "../app/SubscriptionManager";
 import logo from "../img/ntfy.svg";
@@ -118,7 +118,15 @@ const SettingsIcons = (props) => {
         handleClose(event);
         await subscriptionManager.remove(props.subscription.id);
         if (session.exists() && props.subscription.remoteId) {
-            await api.deleteAccountSubscription(config.baseUrl, session.token(), props.subscription.remoteId);
+            try {
+                await api.deleteAccountSubscription(config.baseUrl, session.token(), props.subscription.remoteId);
+            } catch (e) {
+                console.log(`[ActionBar] Error unsubscribing`, e);
+                if ((e instanceof UnauthorizedError)) {
+                    session.reset();
+                    window.location.href = routes.login;
+                }
+            }
         }
         const newSelected = await subscriptionManager.first(); // May be undefined
         if (newSelected) {

+ 13 - 3
web/src/components/App.js

@@ -26,7 +26,7 @@ import {Backdrop, CircularProgress} from "@mui/material";
 import Home from "./Home";
 import Login from "./Login";
 import i18n from "i18next";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import prefs from "../app/Prefs";
 import session from "../app/Session";
 import Pricing from "./Pricing";
@@ -96,8 +96,12 @@ const Layout = () => {
 
     useEffect(() => {
         (async () => {
-            const acc = await api.getAccount(config.baseUrl, session.token());
-            if (acc) {
+            // TODO this should not live here
+            try {
+                if (!session.token()) {
+                    return;
+                }
+                const acc = await api.getAccount(config.baseUrl, session.token());
                 setAccount(acc);
                 if (acc.language) {
                     await i18n.changeLanguage(acc.language);
@@ -116,6 +120,12 @@ const Layout = () => {
                 if (acc.subscriptions) {
                     await subscriptionManager.syncFromRemote(acc.subscriptions);
                 }
+            } catch (e) {
+                console.log(`[App] Error fetching account`, e);
+                if ((e instanceof UnauthorizedError)) {
+                    session.reset();
+                    window.location.href = routes.login;
+                }
             }
         })();
     }, []);

+ 7 - 10
web/src/components/Login.js

@@ -4,7 +4,7 @@ import WarningAmberIcon from '@mui/icons-material/WarningAmber';
 import TextField from "@mui/material/TextField";
 import Button from "@mui/material/Button";
 import Box from "@mui/material/Box";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import routes from "./routes";
 import session from "../app/Session";
 import {NavLink} from "react-router-dom";
@@ -22,17 +22,14 @@ const Login = () => {
         const user = { username, password };
         try {
             const token = await api.login(config.baseUrl, user);
-            if (token) {
-                console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
-                session.store(user.username, token);
-                window.location.href = routes.app;
-            } else {
-                console.log(`[Login] User auth for user ${user.username} failed, access denied`);
-                setError(t("Login failed: Invalid username or password"));
-            }
+            console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
+            session.store(user.username, token);
+            window.location.href = routes.app;
         } catch (e) {
             console.log(`[Login] User auth for user ${user.username} failed`, e);
-            if (e && e.message) {
+            if ((e instanceof UnauthorizedError)) {
+                setError(t("Login failed: Invalid username or password"));
+            } else if (e.message) {
                 setError(e.message);
             } else {
                 setError(t("Unknown error. Check logs for details."))

+ 35 - 27
web/src/components/Preferences.js

@@ -34,8 +34,9 @@ import DialogActions from "@mui/material/DialogActions";
 import userManager from "../app/UserManager";
 import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
 import {useTranslation} from "react-i18next";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import session from "../app/Session";
+import routes from "./routes";
 
 const Preferences = () => {
     return (
@@ -72,13 +73,11 @@ const Sound = () => {
     const sound = useLiveQuery(async () => prefs.sound());
     const handleChange = async (ev) => {
         await prefs.setSound(ev.target.value);
-        if (session.exists()) {
-            await api.updateAccountSettings(config.baseUrl, session.token(), {
-                notification: {
-                    sound: ev.target.value
-                }
-            });
-        }
+        await maybeUpdateAccountSettings({
+            notification: {
+                sound: ev.target.value
+            }
+        });
     }
     if (!sound) {
         return null; // While loading
@@ -112,13 +111,11 @@ const MinPriority = () => {
     const minPriority = useLiveQuery(async () => prefs.minPriority());
     const handleChange = async (ev) => {
         await prefs.setMinPriority(ev.target.value);
-        if (session.exists()) {
-            await api.updateAccountSettings(config.baseUrl, session.token(), {
-                notification: {
-                    min_priority: ev.target.value
-                }
-            });
-        }
+        await maybeUpdateAccountSettings({
+            notification: {
+                min_priority: ev.target.value
+            }
+        });
     }
     if (!minPriority) {
         return null; // While loading
@@ -162,13 +159,11 @@ const DeleteAfter = () => {
     const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
     const handleChange = async (ev) => {
         await prefs.setDeleteAfter(ev.target.value);
-        if (session.exists()) {
-            await api.updateAccountSettings(config.baseUrl, session.token(), {
-                notification: {
-                    delete_after: ev.target.value
-                }
-            });
-        }
+        await maybeUpdateAccountSettings({
+            notification: {
+                delete_after: ev.target.value
+            }
+        });
     }
     if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
         return null; // While loading
@@ -466,11 +461,9 @@ const Language = () => {
 
     const handleChange = async (ev) => {
         await i18n.changeLanguage(ev.target.value);
-        if (session.exists()) {
-            await api.updateAccountSettings(config.baseUrl, session.token(), {
-                language: ev.target.value
-            });
-        }
+        await maybeUpdateAccountSettings({
+            language: ev.target.value
+        });
     };
 
     // Remember: Flags are not languages. Don't put flags next to the language in the list.
@@ -670,4 +663,19 @@ const AccessControlDialog = (props) => {
 };
 */
 
+const maybeUpdateAccountSettings = async (payload) => {
+    if (!session.exists()) {
+        return;
+    }
+    try {
+        await api.updateAccountSettings(config.baseUrl, session.token(), payload);
+    } catch (e) {
+        console.log(`[Preferences] Error updating account settings`, e);
+        if ((e instanceof UnauthorizedError)) {
+            session.reset();
+            window.location.href = routes.login;
+        }
+    }
+};
+
 export default Preferences;

+ 8 - 2
web/src/components/PublishDialog.js

@@ -22,11 +22,12 @@ import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, val
 import Box from "@mui/material/Box";
 import AttachmentIcon from "./AttachmentIcon";
 import DialogFooter from "./DialogFooter";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import userManager from "../app/UserManager";
 import EmojiPicker from "./EmojiPicker";
 import {Trans, useTranslation} from "react-i18next";
 import session from "../app/Session";
+import routes from "./routes";
 
 const PublishDialog = (props) => {
     const { t } = useTranslation();
@@ -178,7 +179,12 @@ const PublishDialog = (props) => {
             setAttachFileError("");
         } catch (e) {
             console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
-            setAttachFileError(""); // Reset error (rely on server-side checking)
+            if ((e instanceof UnauthorizedError)) {
+                session.reset();
+                window.location.href = routes.login;
+            } else {
+                setAttachFileError(""); // Reset error (rely on server-side checking)
+            }
         }
     };
 

+ 4 - 9
web/src/components/Signup.js

@@ -2,7 +2,7 @@ import * as React from 'react';
 import TextField from "@mui/material/TextField";
 import Button from "@mui/material/Button";
 import Box from "@mui/material/Box";
-import api, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/Api";
+import api, {AccountCreateLimitReachedError, UnauthorizedError, UsernameTakenError} from "../app/Api";
 import routes from "./routes";
 import session from "../app/Session";
 import Typography from "@mui/material/Typography";
@@ -24,14 +24,9 @@ const Signup = () => {
         try {
             await api.createAccount(config.baseUrl, user.username, user.password);
             const token = await api.login(config.baseUrl, user);
-            if (token) {
-                console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
-                session.store(user.username, token);
-                window.location.href = routes.app;
-            } else {
-                console.log(`[Signup] Signup for user ${user.username} failed, access denied`);
-                setError(t("Login failed: Invalid username or password"));
-            }
+            console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
+            session.store(user.username, token);
+            window.location.href = routes.app;
         } catch (e) {
             console.log(`[Signup] Signup for user ${user.username} failed`, e);
             if ((e instanceof UsernameTakenError)) {

+ 16 - 6
web/src/components/SubscribeDialog.js

@@ -8,7 +8,7 @@ import DialogContentText from '@mui/material/DialogContentText';
 import DialogTitle from '@mui/material/DialogTitle';
 import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
 import theme from "./theme";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
 import userManager from "../app/UserManager";
 import subscriptionManager from "../app/SubscriptionManager";
@@ -16,6 +16,7 @@ import poller from "../app/Poller";
 import DialogFooter from "./DialogFooter";
 import {useTranslation} from "react-i18next";
 import session from "../app/Session";
+import routes from "./routes";
 
 const publicBaseUrl = "https://ntfy.sh";
 
@@ -25,14 +26,23 @@ const SubscribeDialog = (props) => {
     const [showLoginPage, setShowLoginPage] = useState(false);
     const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
     const handleSuccess = async () => {
+        console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
         const actualBaseUrl = (baseUrl) ? baseUrl : config.baseUrl;
         const subscription = await subscriptionManager.add(actualBaseUrl, topic);
         if (session.exists()) {
-            const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
-                base_url: actualBaseUrl,
-                topic: topic
-            });
-            await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
+            try {
+                const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
+                    base_url: actualBaseUrl,
+                    topic: topic
+                });
+                await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
+            } catch (e) {
+                console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
+                if ((e instanceof UnauthorizedError)) {
+                    session.reset();
+                    window.location.href = routes.login;
+                }
+            }
         }
         poller.pollInBackground(subscription); // Dangle!
         props.onSuccess(subscription);

+ 14 - 6
web/src/components/hooks.js

@@ -8,7 +8,7 @@ import connectionManager from "../app/ConnectionManager";
 import poller from "../app/Poller";
 import pruner from "../app/Pruner";
 import session from "../app/Session";
-import api from "../app/Api";
+import api, {UnauthorizedError} from "../app/Api";
 
 /**
  * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -64,11 +64,19 @@ export const useAutoSubscribe = (subscriptions, selected) => {
             (async () => {
                 const subscription = await subscriptionManager.add(baseUrl, params.topic);
                 if (session.exists()) {
-                    const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
-                        base_url: baseUrl,
-                        topic: params.topic
-                    });
-                    await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
+                    try {
+                        const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
+                            base_url: baseUrl,
+                            topic: params.topic
+                        });
+                        await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
+                    } catch (e) {
+                        console.log(`[App] Auto-subscribing failed`, e);
+                        if ((e instanceof UnauthorizedError)) {
+                            session.reset();
+                            window.location.href = routes.login;
+                        }
+                    }
                 }
                 poller.pollInBackground(subscription); // Dangle!
             })();