mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
refactor: authentication on public routes (#6765)
* refactor: authentication on public routes * fix: remove public user * pr feedback * pr feedback * pr feedback * pr feedback * remove unused method * fix: tests * fix: useless methods * fix: tests * pr feedback * pr feedback * chore: cleanup --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
45ea0bb689
commit
f1e4fdf175
16 changed files with 92 additions and 75 deletions
|
@ -153,9 +153,10 @@ describe(`${AuthController.name} (e2e)`, () => {
|
|||
expect(token).toBeDefined();
|
||||
|
||||
const cookies = headers['set-cookie'];
|
||||
expect(cookies).toHaveLength(2);
|
||||
expect(cookies).toHaveLength(3);
|
||||
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
|
||||
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
|
||||
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
export const MOBILE_REDIRECT = 'app.immich:/';
|
||||
export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
||||
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
|
||||
export const IMMICH_IS_AUTHENTICATED = 'immich_is_authenticated';
|
||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||
export const IMMICH_API_KEY_NAME = 'api_key';
|
||||
export const IMMICH_API_KEY_HEADER = 'x-api-key';
|
||||
|
|
|
@ -29,6 +29,7 @@ import {
|
|||
IMMICH_ACCESS_COOKIE,
|
||||
IMMICH_API_KEY_HEADER,
|
||||
IMMICH_AUTH_TYPE_COOKIE,
|
||||
IMMICH_IS_AUTHENTICATED,
|
||||
LOGIN_URL,
|
||||
MOBILE_REDIRECT,
|
||||
} from './auth.constant';
|
||||
|
@ -429,14 +430,17 @@ export class AuthService {
|
|||
|
||||
let authTypeCookie = '';
|
||||
let accessTokenCookie = '';
|
||||
let isAuthenticatedCookie = '';
|
||||
|
||||
if (isSecure) {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
} else {
|
||||
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
|
||||
}
|
||||
return [accessTokenCookie, authTypeCookie];
|
||||
return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
ChangePasswordDto,
|
||||
IMMICH_ACCESS_COOKIE,
|
||||
IMMICH_AUTH_TYPE_COOKIE,
|
||||
IMMICH_IS_AUTHENTICATED,
|
||||
LoginCredentialDto,
|
||||
LoginDetails,
|
||||
LoginResponseDto,
|
||||
|
@ -84,6 +85,7 @@ export class AuthController {
|
|||
): Promise<LogoutResponseDto> {
|
||||
res.clearCookie(IMMICH_ACCESS_COOKIE);
|
||||
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
|
||||
res.clearCookie(IMMICH_IS_AUTHENTICATED);
|
||||
|
||||
return this.service.logout(auth, (request.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]);
|
||||
}
|
||||
|
|
3
server/test/fixtures/auth.stub.ts
vendored
3
server/test/fixtures/auth.stub.ts
vendored
|
@ -145,6 +145,7 @@ export const loginResponseStub = {
|
|||
cookie: [
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
],
|
||||
},
|
||||
user1password: {
|
||||
|
@ -160,6 +161,7 @@ export const loginResponseStub = {
|
|||
cookie: [
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
],
|
||||
},
|
||||
user1insecure: {
|
||||
|
@ -175,6 +177,7 @@ export const loginResponseStub = {
|
|||
cookie: [
|
||||
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -76,7 +76,6 @@
|
|||
dispatch('firstLogin');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch('success');
|
||||
return;
|
||||
} catch (error) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { api, UserAvatarColor, type UserResponseDto } from '@api';
|
||||
import { api, UserAvatarColor } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
@ -10,9 +10,7 @@
|
|||
import { notificationController, NotificationType } from '../notification/notification';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import AvatarSelector from './avatar-selector.svelte';
|
||||
import { setUser } from '$lib/stores/user.store';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
import { user } from '$lib/stores/user.store';
|
||||
|
||||
let isShowSelectAvatar = false;
|
||||
|
||||
|
@ -23,21 +21,20 @@
|
|||
|
||||
const handleSaveProfile = async (color: UserAvatarColor) => {
|
||||
try {
|
||||
if (user.profileImagePath !== '') {
|
||||
if ($user.profileImagePath !== '') {
|
||||
await api.userApi.deleteProfileImage();
|
||||
}
|
||||
|
||||
const { data } = await api.userApi.updateUser({
|
||||
updateUserDto: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
id: $user.id,
|
||||
email: $user.email,
|
||||
name: $user.name,
|
||||
avatarColor: color,
|
||||
},
|
||||
});
|
||||
|
||||
user = data;
|
||||
setUser(user);
|
||||
$user = data;
|
||||
isShowSelectAvatar = false;
|
||||
|
||||
notificationController.show({
|
||||
|
@ -60,8 +57,8 @@
|
|||
class="mx-4 mt-4 flex flex-col items-center justify-center gap-4 rounded-3xl bg-white p-4 dark:bg-immich-dark-primary/10"
|
||||
>
|
||||
<div class="relative">
|
||||
{#key user}
|
||||
<UserAvatar {user} size="xl" />
|
||||
{#key $user}
|
||||
<UserAvatar user={$user} size="xl" />
|
||||
|
||||
<div
|
||||
class="absolute z-10 bottom-0 right-0 rounded-full w-6 h-6 border dark:border-immich-dark-primary bg-immich-primary"
|
||||
|
@ -77,9 +74,9 @@
|
|||
</div>
|
||||
<div>
|
||||
<p class="text-center text-lg font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
{user.name}
|
||||
{$user.name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{user.email}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-immich-dark-fg">{$user.email}</p>
|
||||
</div>
|
||||
|
||||
<a href={AppRoute.USER_SETTINGS} on:click={() => dispatch('close')}>
|
||||
|
@ -104,7 +101,7 @@
|
|||
</div>
|
||||
{#if isShowSelectAvatar}
|
||||
<AvatarSelector
|
||||
{user}
|
||||
user={$user}
|
||||
on:close={() => (isShowSelectAvatar = false)}
|
||||
on:choose={({ detail: color }) => handleSaveProfile(color)}
|
||||
/>
|
||||
|
|
|
@ -146,7 +146,7 @@
|
|||
{/if}
|
||||
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel user={$user} on:logout={logOut} />
|
||||
<AccountInfoPanel on:logout={logOut} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -3,27 +3,28 @@
|
|||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { api, type UserResponseDto } from '@api';
|
||||
import { api } from '@api';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import SettingInputField, { SettingInputFieldType } from '../admin-page/settings/setting-input-field.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { setUser } from '$lib/stores/user.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
let editedUser = cloneDeep($user);
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
try {
|
||||
const { data } = await api.userApi.updateUser({
|
||||
updateUserDto: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
id: editedUser.id,
|
||||
email: editedUser.email,
|
||||
name: editedUser.name,
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(user, data);
|
||||
setUser(data);
|
||||
Object.assign(editedUser, data);
|
||||
$user = data;
|
||||
|
||||
notificationController.show({
|
||||
message: 'Saved profile',
|
||||
|
@ -42,19 +43,24 @@
|
|||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="USER ID"
|
||||
bind:value={user.id}
|
||||
bind:value={editedUser.id}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
<SettingInputField inputType={SettingInputFieldType.EMAIL} label="EMAIL" bind:value={user.email} />
|
||||
<SettingInputField inputType={SettingInputFieldType.EMAIL} label="EMAIL" bind:value={editedUser.email} />
|
||||
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label="NAME" bind:value={user.name} required={true} />
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="NAME"
|
||||
bind:value={editedUser.name}
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="STORAGE LABEL"
|
||||
disabled={true}
|
||||
value={user.storageLabel || ''}
|
||||
value={editedUser.storageLabel || ''}
|
||||
required={false}
|
||||
/>
|
||||
|
||||
|
@ -62,7 +68,7 @@
|
|||
inputType={SettingInputFieldType.TEXT}
|
||||
label="EXTERNAL PATH"
|
||||
disabled={true}
|
||||
value={user.externalPath || ''}
|
||||
value={editedUser.externalPath || ''}
|
||||
required={false}
|
||||
/>
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="account" title="Account" subtitle="Manage your account">
|
||||
<UserProfileSettings user={$user} />
|
||||
<UserProfileSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="api-keys" title="API Keys" subtitle="Manage your API keys">
|
||||
|
|
|
@ -1,16 +1,8 @@
|
|||
import { get, writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
import type { UserResponseDto } from '@api';
|
||||
|
||||
export let user = writable<UserResponseDto>();
|
||||
|
||||
export const setUser = (value: UserResponseDto) => {
|
||||
user.set(value);
|
||||
};
|
||||
|
||||
export const getSavedUser = () => {
|
||||
return get(user);
|
||||
};
|
||||
|
||||
export const resetSavedUser = () => {
|
||||
user = writable<UserResponseDto>();
|
||||
};
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { AssetResponseDto, ServerVersionResponseDto } from '@api';
|
||||
import { type Socket, io } from 'socket.io-client';
|
||||
import { writable } from 'svelte/store';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { loadConfig } from './server-config.store';
|
||||
import { getAuthUser } from '$lib/utils/auth';
|
||||
import { user } from './user.store';
|
||||
|
||||
export interface ReleaseEvent {
|
||||
isAvailable: boolean;
|
||||
|
@ -30,8 +30,7 @@ export const openWebsocketConnection = async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
const user = await getAuthUser();
|
||||
if (!user) {
|
||||
if (!get(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,53 +1,65 @@
|
|||
import { api } from '@api';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { AppRoute } from '../constants';
|
||||
import { getSavedUser, setUser } from '$lib/stores/user.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { serverInfo } from '$lib/stores/server-info.store';
|
||||
import { browser } from '$app/environment';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
|
||||
export interface AuthOptions {
|
||||
admin?: true;
|
||||
public?: true;
|
||||
}
|
||||
|
||||
export const getAuthUser = async () => {
|
||||
export const loadUser = async () => {
|
||||
try {
|
||||
const { data: user } = await api.userApi.getMyUserInfo();
|
||||
return user;
|
||||
let loaded = get(user);
|
||||
if (!loaded && hasAuthCookie()) {
|
||||
const { data } = await api.userApi.getMyUserInfo();
|
||||
loaded = data;
|
||||
user.set(loaded);
|
||||
}
|
||||
return loaded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticate = async (options?: AuthOptions) => {
|
||||
options = options || {};
|
||||
const hasAuthCookie = (): boolean => {
|
||||
if (!browser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedUser = getSavedUser();
|
||||
const user = savedUser || (await getAuthUser());
|
||||
for (const cookie of document.cookie.split('; ')) {
|
||||
const [name] = cookie.split('=');
|
||||
if (name === 'immich_is_authenticated') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const authenticate = async (options?: AuthOptions) => {
|
||||
const { public: publicRoute, admin: adminRoute } = options || {};
|
||||
const user = await loadUser();
|
||||
|
||||
if (publicRoute) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
redirect(302, AppRoute.AUTH_LOGIN);
|
||||
}
|
||||
|
||||
if (options.admin && !user.isAdmin) {
|
||||
if (adminRoute && !user.isAdmin) {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}
|
||||
|
||||
if (!savedUser) {
|
||||
setUser(user);
|
||||
}
|
||||
};
|
||||
|
||||
export const requestServerInfo = async () => {
|
||||
if (getSavedUser()) {
|
||||
if (get(user)) {
|
||||
const { data } = await api.serverInfoApi.getServerInfo();
|
||||
serverInfo.set(data);
|
||||
}
|
||||
};
|
||||
|
||||
export const isLoggedIn = async () => {
|
||||
const savedUser = getSavedUser();
|
||||
const user = savedUser || (await getAuthUser());
|
||||
if (!savedUser) {
|
||||
setUser(user);
|
||||
}
|
||||
return user;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getAuthUser } from '$lib/utils/auth';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { api, ThumbnailFormat } from '@api';
|
||||
import type { AxiosError } from 'axios';
|
||||
import type { PageLoad } from './$types';
|
||||
|
@ -6,7 +6,7 @@ import { error as throwError } from '@sveltejs/kit';
|
|||
|
||||
export const load = (async ({ params }) => {
|
||||
const { key } = params;
|
||||
await getAuthUser();
|
||||
await authenticate({ public: true });
|
||||
|
||||
try {
|
||||
const { data: sharedLink } = await api.sharedLinkApi.getMySharedLink({ key });
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { AppRoute } from '$lib/constants';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { api } from '../api';
|
||||
import { isLoggedIn } from '../lib/utils/auth';
|
||||
import { loadUser } from '../lib/utils/auth';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const ssr = false;
|
||||
export const csr = true;
|
||||
|
||||
export const load = (async () => {
|
||||
const authenticated = await isLoggedIn();
|
||||
const authenticated = await loadUser();
|
||||
if (authenticated) {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}
|
||||
|
|
|
@ -2,11 +2,12 @@ import { AppRoute } from '$lib/constants';
|
|||
import { authenticate } from '$lib/utils/auth';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
import { getSavedUser } from '$lib/stores/user.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
|
||||
export const load = (async () => {
|
||||
await authenticate();
|
||||
if (!getSavedUser().shouldChangePassword) {
|
||||
if (!get(user).shouldChangePassword) {
|
||||
redirect(302, AppRoute.PHOTOS);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue