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
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

View File

@ -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();

View File

@ -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
}
};

View File

@ -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) {

View File

@ -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;
}
}
})();
}, []);

View File

@ -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."))

View File

@ -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;

View File

@ -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)
}
}
};

View File

@ -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)) {

View File

@ -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);

View File

@ -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!
})();