Redirect UI if unauthorized API response

pull/584/head
binwiederhier 2022-12-24 15:51:22 -05:00
parent 1b39ba70cb
commit 3aac1b2715
11 changed files with 148 additions and 77 deletions

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import MenuList from '@mui/material/MenuList';
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsIcon from '@mui/icons-material/Notifications'; import NotificationsIcon from '@mui/icons-material/Notifications';
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
import api from "../app/Api"; import api, {UnauthorizedError} from "../app/Api";
import routes from "./routes"; import routes from "./routes";
import subscriptionManager from "../app/SubscriptionManager"; import subscriptionManager from "../app/SubscriptionManager";
import logo from "../img/ntfy.svg"; import logo from "../img/ntfy.svg";
@ -118,7 +118,15 @@ const SettingsIcons = (props) => {
handleClose(event); handleClose(event);
await subscriptionManager.remove(props.subscription.id); await subscriptionManager.remove(props.subscription.id);
if (session.exists() && props.subscription.remoteId) { 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 const newSelected = await subscriptionManager.first(); // May be undefined
if (newSelected) { if (newSelected) {

View File

@ -26,7 +26,7 @@ import {Backdrop, CircularProgress} from "@mui/material";
import Home from "./Home"; import Home from "./Home";
import Login from "./Login"; import Login from "./Login";
import i18n from "i18next"; import i18n from "i18next";
import api from "../app/Api"; import api, {UnauthorizedError} from "../app/Api";
import prefs from "../app/Prefs"; import prefs from "../app/Prefs";
import session from "../app/Session"; import session from "../app/Session";
import Pricing from "./Pricing"; import Pricing from "./Pricing";
@ -96,8 +96,12 @@ const Layout = () => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const acc = await api.getAccount(config.baseUrl, session.token()); // TODO this should not live here
if (acc) { try {
if (!session.token()) {
return;
}
const acc = await api.getAccount(config.baseUrl, session.token());
setAccount(acc); setAccount(acc);
if (acc.language) { if (acc.language) {
await i18n.changeLanguage(acc.language); await i18n.changeLanguage(acc.language);
@ -116,6 +120,12 @@ const Layout = () => {
if (acc.subscriptions) { if (acc.subscriptions) {
await subscriptionManager.syncFromRemote(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;
}
} }
})(); })();
}, []); }, []);

View File

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

View File

@ -34,8 +34,9 @@ import DialogActions from "@mui/material/DialogActions";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils"; import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
import {useTranslation} from "react-i18next"; import {useTranslation} from "react-i18next";
import api from "../app/Api"; import api, {UnauthorizedError} from "../app/Api";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes";
const Preferences = () => { const Preferences = () => {
return ( return (
@ -72,13 +73,11 @@ const Sound = () => {
const sound = useLiveQuery(async () => prefs.sound()); const sound = useLiveQuery(async () => prefs.sound());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setSound(ev.target.value); await prefs.setSound(ev.target.value);
if (session.exists()) { await maybeUpdateAccountSettings({
await api.updateAccountSettings(config.baseUrl, session.token(), { notification: {
notification: { sound: ev.target.value
sound: ev.target.value }
} });
});
}
} }
if (!sound) { if (!sound) {
return null; // While loading return null; // While loading
@ -112,13 +111,11 @@ const MinPriority = () => {
const minPriority = useLiveQuery(async () => prefs.minPriority()); const minPriority = useLiveQuery(async () => prefs.minPriority());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setMinPriority(ev.target.value); await prefs.setMinPriority(ev.target.value);
if (session.exists()) { await maybeUpdateAccountSettings({
await api.updateAccountSettings(config.baseUrl, session.token(), { notification: {
notification: { min_priority: ev.target.value
min_priority: ev.target.value }
} });
});
}
} }
if (!minPriority) { if (!minPriority) {
return null; // While loading return null; // While loading
@ -162,13 +159,11 @@ const DeleteAfter = () => {
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
const handleChange = async (ev) => { const handleChange = async (ev) => {
await prefs.setDeleteAfter(ev.target.value); await prefs.setDeleteAfter(ev.target.value);
if (session.exists()) { await maybeUpdateAccountSettings({
await api.updateAccountSettings(config.baseUrl, session.token(), { notification: {
notification: { delete_after: ev.target.value
delete_after: ev.target.value }
} });
});
}
} }
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
return null; // While loading return null; // While loading
@ -466,11 +461,9 @@ const Language = () => {
const handleChange = async (ev) => { const handleChange = async (ev) => {
await i18n.changeLanguage(ev.target.value); await i18n.changeLanguage(ev.target.value);
if (session.exists()) { await maybeUpdateAccountSettings({
await api.updateAccountSettings(config.baseUrl, session.token(), { language: ev.target.value
language: ev.target.value });
});
}
}; };
// Remember: Flags are not languages. Don't put flags next to the language in the list. // 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; export default Preferences;

View File

@ -22,11 +22,12 @@ import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, val
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import api from "../app/Api"; import api, {UnauthorizedError} from "../app/Api";
import userManager from "../app/UserManager"; import userManager from "../app/UserManager";
import EmojiPicker from "./EmojiPicker"; import EmojiPicker from "./EmojiPicker";
import {Trans, useTranslation} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session"; import session from "../app/Session";
import routes from "./routes";
const PublishDialog = (props) => { const PublishDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -178,7 +179,12 @@ const PublishDialog = (props) => {
setAttachFileError(""); setAttachFileError("");
} catch (e) { } catch (e) {
console.log(`[PublishDialog] Retrieving attachment limits failed`, 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)
}
} }
}; };

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import connectionManager from "../app/ConnectionManager";
import poller from "../app/Poller"; import poller from "../app/Poller";
import pruner from "../app/Pruner"; import pruner from "../app/Pruner";
import session from "../app/Session"; 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 * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@ -64,11 +64,19 @@ export const useAutoSubscribe = (subscriptions, selected) => {
(async () => { (async () => {
const subscription = await subscriptionManager.add(baseUrl, params.topic); const subscription = await subscriptionManager.add(baseUrl, params.topic);
if (session.exists()) { if (session.exists()) {
const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), { try {
base_url: baseUrl, const remoteSubscription = await api.addAccountSubscription(config.baseUrl, session.token(), {
topic: params.topic base_url: baseUrl,
}); topic: params.topic
await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id); });
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! poller.pollInBackground(subscription); // Dangle!
})(); })();