initial implementation for password saving (#132)

* initial implementation for password saving

* support restoring password in interceptor

* Fix modal overflow and position styles

* warn about 429, better error handling

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
Co-authored-by: Jeff <42182408+jeffvli@users.noreply.github.com>
This commit is contained in:
Kendall Garner 2023-06-13 17:52:51 +00:00 committed by GitHub
parent a3a84766e4
commit 2fac9efc1b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 310 additions and 62 deletions

View file

@ -1,4 +1,4 @@
import { ipcMain } from 'electron';
import { ipcMain, safeStorage } from 'electron';
import Store from 'electron-store';
export const store = new Store();
@ -10,3 +10,41 @@ ipcMain.handle('settings-get', (_event, data: { property: string }) => {
ipcMain.on('settings-set', (__event, data: { property: string; value: any }) => {
store.set(`${data.property}`, data.value);
});
ipcMain.handle('password-get', (_event, server: string): string | null => {
if (safeStorage.isEncryptionAvailable()) {
const servers = store.get('server') as Record<string, string> | undefined;
if (!servers) {
return null;
}
const encrypted = servers[server];
if (!encrypted) return null;
const decrypted = safeStorage.decryptString(Buffer.from(encrypted, 'hex'));
return decrypted;
}
return null;
});
ipcMain.on('password-remove', (_event, server: string) => {
const passwords = store.get('server', {}) as Record<string, string>;
if (server in passwords) {
delete passwords[server];
}
store.set({ server: passwords });
});
ipcMain.handle('password-set', (_event, password: string, server: string) => {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(password);
const passwords = store.get('server', {}) as Record<string, string>;
passwords[server] = encrypted.toString('hex');
store.set({ server: passwords });
return true;
}
return false;
});

View file

@ -23,6 +23,18 @@ const disableMediaKeys = () => {
ipcRenderer.send('global-media-keys-disable');
};
const passwordGet = async (server: string): Promise<string | null> => {
return ipcRenderer.invoke('password-get', server);
};
const passwordRemove = (server: string) => {
ipcRenderer.send('password-remove', server);
};
const passwordSet = async (password: string, server: string): Promise<boolean> => {
return ipcRenderer.invoke('password-set', password, server);
};
const setZoomFactor = (zoomFactor: number) => {
webFrame.setZoomFactor(zoomFactor / 100);
};
@ -31,6 +43,9 @@ export const localSettings = {
disableMediaKeys,
enableMediaKeys,
get,
passwordGet,
passwordRemove,
passwordSet,
restart,
set,
setZoomFactor,

View file

@ -3,10 +3,10 @@ import { jfType } from '/@/renderer/api/jellyfin/jellyfin-types';
import { initClient, initContract } from '@ts-rest/core';
import axios, { AxiosError, AxiosResponse, isAxiosError, Method } from 'axios';
import qs from 'qs';
import { toast } from '/@/renderer/components';
import { ServerListItem } from '/@/renderer/types';
import omitBy from 'lodash/omitBy';
import { z } from 'zod';
import { authenticationFailure } from '/@/renderer/api/utils';
const c = initContract();
@ -269,19 +269,9 @@ axiosClient.interceptors.response.use(
},
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const currentServer = useAuthStore.getState().currentServer;
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.credential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.setCurrentServer(null);
useAuthStore.getState().actions.updateServer(serverId, { credential: undefined });
}
authenticationFailure(currentServer);
}
return Promise.reject(error);

View file

@ -1,12 +1,16 @@
import { initClient, initContract } from '@ts-rest/core';
import axios, { Method, AxiosError, AxiosResponse, isAxiosError } from 'axios';
import isElectron from 'is-electron';
import { debounce } from 'lodash';
import omitBy from 'lodash/omitBy';
import qs from 'qs';
import { ndType } from './navidrome-types';
import { resultWithHeaders } from '/@/renderer/api/utils';
import { toast } from '/@/renderer/components/toast/index';
import { authenticationFailure, resultWithHeaders } from '/@/renderer/api/utils';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
import { toast } from '/@/renderer/components';
const localSettings = isElectron() ? window.electron.localSettings : null;
const c = initContract();
@ -168,43 +172,6 @@ axiosClient.defaults.paramsSerializer = (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' });
};
axiosClient.interceptors.response.use(
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
if (headerCredential) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: headerCredential,
});
}
}
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
toast.error({
message: 'Your session has expired.',
});
const currentServer = useAuthStore.getState().currentServer;
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
}
return Promise.reject(error);
},
);
const parsePath = (fullPath: string) => {
const [path, params] = fullPath.split('?');
@ -231,6 +198,134 @@ const parsePath = (fullPath: string) => {
};
};
let authSuccess = true;
let shouldDelay = false;
const RETRY_DELAY_MS = 1000;
const MAX_RETRIES = 5;
const waitForResult = async (count = 0): Promise<void> => {
return new Promise((resolve) => {
if (count === MAX_RETRIES || !shouldDelay) resolve();
setTimeout(() => {
waitForResult(count + 1)
.then(resolve)
.catch(resolve);
}, RETRY_DELAY_MS);
});
};
const limitedFail = debounce(authenticationFailure, RETRY_DELAY_MS);
const TIMEOUT_ERROR = Error();
axiosClient.interceptors.response.use(
(response) => {
const serverId = useAuthStore.getState().currentServer?.id;
if (serverId) {
const headerCredential = response.headers['x-nd-authorization'] as string | undefined;
if (headerCredential) {
useAuthStore.getState().actions.updateServer(serverId, {
ndCredential: headerCredential,
});
}
}
authSuccess = true;
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
const currentServer = useAuthStore.getState().currentServer;
if (localSettings && currentServer?.savePassword) {
// eslint-disable-next-line promise/no-promise-in-callback
return localSettings
.passwordGet(currentServer.id)
.then(async (password: string | null) => {
authSuccess = false;
if (password === null) {
throw error;
}
if (shouldDelay) {
await waitForResult();
// Hopefully the delay was sufficient for authentication.
// Otherwise, it will require manual intervention
if (authSuccess) {
return axiosClient.request(error.config);
}
throw error;
}
shouldDelay = true;
// Do not use axiosClient. Instead, manually make a post
const res = await axios.post(`${currentServer.url}/auth/login`, {
password,
username: currentServer.username,
});
if (res.status === 429) {
toast.error({
message:
'you have exceeded the number of allowed login requests. Please wait before logging, or consider tweaking AuthRequestLimit',
title: 'Your session has expired.',
});
const serverId = currentServer.id;
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
// special error to prevent sending a second message, and stop other messages that could be enqueued
limitedFail.cancel();
throw TIMEOUT_ERROR;
}
if (res.status !== 200) {
throw new Error('Failed to authenticate');
}
const newCredential = res.data.token;
const subsonicCredential = `u=${currentServer.username}&s=${res.data.subsonicSalt}&t=${res.data.subsonicToken}`;
useAuthStore.getState().actions.updateServer(currentServer.id, {
credential: subsonicCredential,
ndCredential: newCredential,
});
error.config.headers['x-nd-authorization'] = `Bearer ${newCredential}`;
authSuccess = true;
return axiosClient.request(error.config);
})
.catch((newError: any) => {
if (newError !== TIMEOUT_ERROR) {
console.error('Error when trying to reauthenticate: ', newError);
limitedFail(currentServer);
}
// make sure to pass the error so axios will error later on
throw newError;
})
.finally(() => {
shouldDelay = false;
});
}
limitedFail(currentServer);
}
return Promise.reject(error);
},
);
export const ndApiClient = (args: {
server: ServerListItem | null;
signal?: AbortSignal;
@ -253,6 +348,8 @@ export const ndApiClient = (args: {
}
try {
if (shouldDelay) await waitForResult();
const result = await axiosClient.request({
data: body,
headers: {

View file

@ -1,5 +1,8 @@
import { AxiosHeaders } from 'axios';
import { z } from 'zod';
import { toast } from '/@/renderer/components';
import { useAuthStore } from '/@/renderer/store';
import { ServerListItem } from '/@/renderer/types';
// Since ts-rest client returns a strict response type, we need to add the headers to the body object
export const resultWithHeaders = <ItemType extends z.ZodTypeAny>(itemSchema: ItemType) => {
@ -21,3 +24,17 @@ export const resultSubsonicBaseResponse = <ItemType extends z.ZodRawShape>(
.extend(itemSchema),
});
};
export const authenticationFailure = (currentServer: ServerListItem | null) => {
toast.error({
message: 'Your session has expired.',
});
if (currentServer) {
const serverId = currentServer.id;
const token = currentServer.ndCredential;
console.log(`token is expired: ${token}`);
useAuthStore.getState().actions.updateServer(serverId, { ndCredential: undefined });
useAuthStore.getState().actions.setCurrentServer(null);
}
};

View file

@ -166,6 +166,10 @@ export const App = () => {
<ModalsProvider
modalProps={{
centered: true,
styles: {
body: { position: 'relative' },
content: { overflow: 'auto' },
},
transitionProps: {
duration: 300,
exitDuration: 300,

View file

@ -4,12 +4,15 @@ import { Button, PasswordInput, SegmentedControl, TextInput, toast } from '/@/re
import { useForm } from '@mantine/form';
import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
import { ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
const localSettings = isElectron() ? window.electron.localSettings : null;
const SERVER_TYPES = [
{ label: 'Jellyfin', value: ServerType.JELLYFIN },
{ label: 'Navidrome', value: ServerType.NAVIDROME },
@ -31,6 +34,7 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
legacyAuth: false,
name: '',
password: '',
savePassword: false,
type: ServerType.JELLYFIN,
url: 'http://',
username: '',
@ -83,6 +87,13 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
} else {
toast.success({ message: 'Server has been added' });
}
if (localSettings && values.savePassword) {
const saved = await localSettings.passwordSet(values.password, serverItem.id);
if (!saved) {
toast.error({ message: 'Could not save password' });
}
}
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
@ -120,6 +131,14 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
label="Password"
{...form.getInputProps('password')}
/>
{localSettings && form.values.type === ServerType.NAVIDROME && (
<Checkbox
label="Save password"
{...form.getInputProps('savePassword', {
type: 'checkbox',
})}
/>
)}
{form.values.type === ServerType.SUBSONIC && (
<Checkbox
label="Enable legacy authentication"

View file

@ -1,18 +1,22 @@
import { useState } from 'react';
import { Checkbox, Stack, Group } from '@mantine/core';
import { Button, PasswordInput, TextInput, toast, Tooltip } from '/@/renderer/components';
import { Stack, Group } from '@mantine/core';
import { Button, Checkbox, PasswordInput, TextInput, toast, Tooltip } from '/@/renderer/components';
import { useForm } from '@mantine/form';
import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { RiInformationLine } from 'react-icons/ri';
import { AuthenticationResponse } from '/@/renderer/api/types';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem, ServerType } from '/@/renderer/types';
import { api } from '/@/renderer/api';
const localSettings = isElectron() ? window.electron.localSettings : null;
interface EditServerFormProps {
isUpdate?: boolean;
onCancel: () => void;
password?: string;
server: ServerListItem;
}
@ -26,7 +30,7 @@ const ModifiedFieldIndicator = () => {
);
};
export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormProps) => {
export const EditServerForm = ({ isUpdate, password, server, onCancel }: EditServerFormProps) => {
const { updateServer } = useAuthStoreActions();
const focusTrapRef = useFocusTrap();
const [isLoading, setIsLoading] = useState(false);
@ -35,7 +39,8 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
initialValues: {
legacyAuth: false,
name: server?.name,
password: '',
password: password || '',
savePassword: server.savePassword || false,
type: server?.type,
url: server?.url,
username: server?.username,
@ -43,6 +48,7 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
});
const isSubsonic = form.values.type === ServerType.SUBSONIC;
const isNavidrome = form.values.type === ServerType.NAVIDROME;
const handleSubmit = form.onSubmit(async (values) => {
const authFunction = api.controller.authenticate;
@ -71,6 +77,7 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
credential: data.credential,
name: values.name,
ndCredential: data.ndCredential,
savePassword: values.savePassword,
type: values.type,
url: values.url,
userId: data.userId,
@ -79,6 +86,17 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
updateServer(server.id, serverItem);
toast.success({ message: 'Server has been updated' });
if (localSettings) {
if (values.savePassword) {
const saved = await localSettings.passwordSet(values.password, server.id);
if (!saved) {
toast.error({ message: 'Could not save password' });
}
} else {
localSettings.passwordRemove(server.id);
}
}
} catch (err: any) {
setIsLoading(false);
return toast.error({ message: err?.message });
@ -115,6 +133,14 @@ export const EditServerForm = ({ isUpdate, server, onCancel }: EditServerFormPro
label="Password"
{...form.getInputProps('password')}
/>
{localSettings && isNavidrome && (
<Checkbox
label="Save password"
{...form.getInputProps('savePassword', {
type: 'checkbox',
})}
/>
)}
{isSubsonic && (
<Checkbox
label="Enable legacy authentication"

View file

@ -1,24 +1,54 @@
import { useCallback, useState } from 'react';
import { Stack, Group, Divider } from '@mantine/core';
import { Button, Text, TimeoutButton } from '/@/renderer/components';
import { useDisclosure } from '@mantine/hooks';
import isElectron from 'is-electron';
import { RiDeleteBin2Line, RiEdit2Fill } from 'react-icons/ri';
import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form';
import { ServerSection } from '/@/renderer/features/servers/components/server-section';
import { useAuthStoreActions } from '/@/renderer/store';
import { ServerListItem as ServerItem } from '/@/renderer/types';
const localSettings = isElectron() ? window.electron.localSettings : null;
interface ServerListItemProps {
server: ServerItem;
}
export const ServerListItem = ({ server }: ServerListItemProps) => {
const [edit, editHandlers] = useDisclosure(false);
const [savedPassword, setSavedPassword] = useState('');
const { deleteServer } = useAuthStoreActions();
const handleDeleteServer = () => {
deleteServer(server.id);
localSettings?.passwordRemove(server.name);
};
const handleEdit = useCallback(() => {
if (!edit && localSettings && server.savePassword) {
localSettings
.passwordGet(server.id)
.then((password: string | null) => {
if (password) {
setSavedPassword(password);
} else {
setSavedPassword('');
}
editHandlers.open();
return null;
})
.catch((error: any) => {
console.error(error);
setSavedPassword('');
editHandlers.open();
});
} else {
setSavedPassword('');
editHandlers.open();
}
}, [edit, editHandlers, server.id, server.savePassword]);
return (
<Stack>
<ServerSection
@ -30,6 +60,7 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
>
{edit ? (
<EditServerForm
password={savedPassword}
server={server}
onCancel={() => editHandlers.toggle()}
/>
@ -50,7 +81,7 @@ export const ServerListItem = ({ server }: ServerListItemProps) => {
leftIcon={<RiEdit2Fill />}
tooltip={{ label: 'Edit server details' }}
variant="subtle"
onClick={() => editHandlers.toggle()}
onClick={() => handleEdit()}
>
Edit
</Button>

View file

@ -31,6 +31,7 @@ import { ServerListItem, ServerType } from '/@/renderer/types';
import packageJson from '../../../../../package.json';
const browser = isElectron() ? window.electron.browser : null;
const localSettings = isElectron() ? window.electron.localSettings : null;
export const AppMenu = () => {
const navigate = useNavigate();
@ -45,11 +46,21 @@ export const AppMenu = () => {
setCurrentServer(server);
};
const handleCredentialsModal = (server: ServerListItem) => {
const handleCredentialsModal = async (server: ServerListItem) => {
let password: string | undefined;
try {
if (localSettings && server.savePassword) {
password = await localSettings.passwordGet(server.id);
}
} catch (error) {
console.error(error);
}
openModal({
children: server && (
<EditServerForm
isUpdate
password={password}
server={server}
onCancel={closeAllModals}
/>

View file

@ -16,6 +16,9 @@ declare global {
source: string,
lyric: InternetProviderLyricResponse,
): void;
PASSWORD_GET(server: string): Promise<string | null>;
PASSWORD_REMOVE(server: string): void;
PASSWORD_SET(password: string, server: string): Promise<boolean>;
PLAYER_AUTO_NEXT(data: PlayerData): void;
PLAYER_CURRENT_TIME(): void;
PLAYER_GET_TIME(): number | undefined;

View file

@ -126,10 +126,6 @@ button {
animation: fadeOut 0.2s forwards;
}
.mantine-Modal-content {
overflow: hidden;
}
@font-face {
font-family: 'Archivo';
src: url('../fonts/Archivo-VariableFont_wdth,wght.ttf') format('truetype-variations');

View file

@ -51,6 +51,7 @@ export type ServerListItem = {
id: string;
name: string;
ndCredential?: string;
savePassword?: boolean;
type: ServerType;
url: string;
userId: string | null;