Add login redirection

*  Add login redirection

* 🚑 Fix cross site scripting using server side regex validation

*  Add unit test
This commit is contained in:
Manuel
2023-09-24 16:04:07 +02:00
committed by GitHub
parent 7d7fe6016b
commit 690c627f81
12 changed files with 275 additions and 65 deletions

View File

@@ -41,6 +41,7 @@ jobs:
permissions: permissions:
packages: write packages: write
contents: read contents: read
pull-requests: write
steps: steps:
- name: Setup - name: Setup
@@ -74,7 +75,11 @@ jobs:
- run: yarn turbo build - run: yarn turbo build
- run: yarn test:run - run: yarn test:coverage
- name: Report coverage
if: always()
uses: davelosert/vitest-coverage-report-action@v2
- name: Docker meta - name: Docker meta
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'

View File

@@ -120,6 +120,7 @@
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
"@vitest/coverage-c8": "^0.33.0", "@vitest/coverage-c8": "^0.33.0",
"@vitest/coverage-v8": "^0.34.5",
"@vitest/ui": "^0.34.4", "@vitest/ui": "^0.34.4",
"eslint": "^8.0.1", "eslint": "^8.0.1",
"eslint-config-next": "^13.4.5", "eslint-config-next": "^13.4.5",

View File

@@ -13,7 +13,8 @@
}, },
"buttons": { "buttons": {
"submit": "Sign in" "submit": "Sign in"
} },
"afterLoginRedirection": "After login, you'll be redirected to {{url}}"
}, },
"alert": "Your credentials are incorrect or this account doesn't exist. Please try again." "alert": "Your credentials are incorrect or this account doesn't exist. Please try again."
} }

View File

@@ -12,7 +12,7 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconAlertTriangle } from '@tabler/icons-react'; import { IconAlertTriangle } from '@tabler/icons-react';
import { GetServerSideProps } from 'next'; import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import Head from 'next/head'; import Head from 'next/head';
@@ -20,14 +20,16 @@ import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { z } from 'zod'; import { z } from 'zod';
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground'; import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
import { signInSchema } from '~/validations/user'; import { signInSchema } from '~/validations/user';
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
export default function LoginPage() { export default function LoginPage({
redirectAfterLogin,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { t } = useTranslation('authentication/login'); const { t } = useTranslation('authentication/login');
const { i18nZodResolver } = useI18nZodResolver(); const { i18nZodResolver } = useI18nZodResolver();
const router = useRouter(); const router = useRouter();
@@ -54,7 +56,7 @@ export default function LoginPage() {
setIsError(true); setIsError(true);
return; return;
} }
router.push('/manage'); router.push(redirectAfterLogin ?? '/manage');
}); });
}; };
@@ -68,7 +70,7 @@ export default function LoginPage() {
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center"> <Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
<FloatingBackground /> <FloatingBackground />
<ThemeSchemeToggle pos="absolute" top={20} right={20}/> <ThemeSchemeToggle pos="absolute" top={20} right={20} />
<Stack spacing={40} align="center" w="100%"> <Stack spacing={40} align="center" w="100%">
<Stack spacing={0} align="center"> <Stack spacing={0} align="center">
<Image src="/imgs/logo/logo.svg" width={80} height={80} alt="" /> <Image src="/imgs/logo/logo.svg" width={80} height={80} alt="" />
@@ -120,6 +122,12 @@ export default function LoginPage() {
<Button mt="xs" variant="light" fullWidth type="submit" loading={isLoading}> <Button mt="xs" variant="light" fullWidth type="submit" loading={isLoading}>
{t('form.buttons.submit')} {t('form.buttons.submit')}
</Button> </Button>
{redirectAfterLogin && (
<Text color="dimmed" align="center" size="xs">
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
</Text>
)}
</Stack> </Stack>
</form> </form>
</Card> </Card>
@@ -129,9 +137,16 @@ export default function LoginPage() {
); );
} }
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res }) => { const regexExp = /^\/{1}[A-z\/]*$/;
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, query }) => {
const session = await getServerAuthSession({ req, res }); const session = await getServerAuthSession({ req, res });
const zodResult = await z
.object({ redirectAfterLogin: z.string().regex(regexExp) })
.safeParseAsync(query);
const redirectAfterLogin = zodResult.success ? zodResult.data.redirectAfterLogin : null;
if (session) { if (session) {
return { return {
redirect: { redirect: {
@@ -144,6 +159,7 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res
return { return {
props: { props: {
...(await getServerSideTranslations(['authentication/login'], locale, req, res)), ...(await getServerSideTranslations(['authentication/login'], locale, req, res)),
redirectAfterLogin,
}, },
}; };
}; };

View File

@@ -5,12 +5,13 @@ import { Dashboard } from '~/components/Dashboard/Dashboard';
import { BoardLayout } from '~/components/layout/Templates/BoardLayout'; import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
import { useInitConfig } from '~/config/init'; import { useInitConfig } from '~/config/init';
import { env } from '~/env'; import { env } from '~/env';
import { getServerAuthSession } from '~/server/auth';
import { configExists } from '~/tools/config/configExists'; import { configExists } from '~/tools/config/configExists';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { boardNamespaces } from '~/tools/server/translation-namespaces'; import { boardNamespaces } from '~/tools/server/translation-namespaces';
import { ConfigType } from '~/types/config'; import { ConfigType } from '~/types/config';
import { getServerAuthSession } from '~/server/auth';
export default function BoardPage({ export default function BoardPage({
config: initialConfig, config: initialConfig,
@@ -35,13 +36,8 @@ const routeParamsSchema = z.object({
slug: z.string(), slug: z.string(),
}); });
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async ({ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
params, const routeParams = routeParamsSchema.safeParse(ctx.params);
locale,
req,
res,
}) => {
const routeParams = routeParamsSchema.safeParse(params);
if (!routeParams.success) { if (!routeParams.success) {
return { return {
notFound: true, notFound: true,
@@ -56,38 +52,32 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
} }
const config = await getFrontendConfig(routeParams.data.slug); const config = await getFrontendConfig(routeParams.data.slug);
const translations = await getServerSideTranslations(boardNamespaces, locale, req, res); const translations = await getServerSideTranslations(
boardNamespaces,
ctx.locale,
ctx.req,
ctx.res
);
const getSuccessResponse = () => { const session = await getServerAuthSession({ req: ctx.req, res: ctx.res });
return {
props: { const result = checkForSessionOrAskForLogin(
config, ctx,
primaryColor: config.settings.customization.colors.primary, session,
secondaryColor: config.settings.customization.colors.secondary, () => config.settings.access.allowGuests || !session?.user
primaryShade: config.settings.customization.colors.shade, );
dockerEnabled: !!env.DOCKER_HOST && !!env.DOCKER_PORT, if (result) {
...translations, return result;
},
};
} }
return {
if (!config.settings.access.allowGuests) { props: {
const session = await getServerAuthSession({ req, res }); config,
primaryColor: config.settings.customization.colors.primary,
if (session?.user) { secondaryColor: config.settings.customization.colors.secondary,
return getSuccessResponse(); primaryShade: config.settings.customization.colors.shade,
} dockerEnabled: !!env.DOCKER_HOST && !!env.DOCKER_PORT,
...translations,
return { },
notFound: true, };
props: {
primaryColor: config.settings.customization.colors.primary,
secondaryColor: config.settings.customization.colors.secondary,
primaryShade: config.settings.customization.colors.shade,
}
};
}
return getSuccessResponse();
}; };

View File

@@ -12,7 +12,6 @@ import {
Title, Title,
} from '@mantine/core'; } from '@mantine/core';
import { useListState } from '@mantine/hooks'; import { useListState } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { import {
IconBox, IconBox,
IconCategory, IconCategory,
@@ -35,6 +34,7 @@ import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { sleep } from '~/tools/client/time'; import { sleep } from '~/tools/client/time';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
@@ -204,10 +204,9 @@ const BoardsPage = () => {
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx); const session = await getServerAuthSession(ctx);
if (!session?.user) { const result = checkForSessionOrAskForLogin(ctx, session, () => true);
return { if (result) {
notFound: true, return result;
};
} }
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(

View File

@@ -19,6 +19,7 @@ import {
import { ManageLayout } from '~/components/layout/Templates/ManageLayout'; import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
import { getServerAuthSession } from '~/server/auth'; import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { manageNamespaces } from '~/tools/server/translation-namespaces'; import { manageNamespaces } from '~/tools/server/translation-namespaces';
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver'; import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
@@ -132,10 +133,9 @@ export type CreateAccountSchema = z.infer<typeof createAccountSchema>;
export const getServerSideProps: GetServerSideProps = async (ctx) => { export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getServerAuthSession(ctx); const session = await getServerAuthSession(ctx);
if (!session?.user.isAdmin) { const result = checkForSessionOrAskForLogin(ctx, session, () => session?.user.isAdmin == true);
return { if (result) {
notFound: true, return result;
};
} }
const translations = await getServerSideTranslations( const translations = await getServerSideTranslations(

View File

@@ -0,0 +1,34 @@
import {
GetServerSideProps,
GetServerSidePropsContext,
GetServerSidePropsResult,
PreviewData,
} from 'next';
import { Session } from 'next-auth';
import { ParsedUrlQuery } from 'querystring';
export const checkForSessionOrAskForLogin = (
context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>,
session: Session | null,
accessCallback: () => boolean,
): GetServerSidePropsResult<any> | undefined => {
if (!session?.user) {
console.log('detected logged out user!');
return {
props: {},
redirect: {
destination: `/auth/login?redirectAfterLogin=${context.resolvedUrl}`,
permanent: false,
},
};
}
if (!accessCallback()) {
return {
props: {},
notFound: true
}
}
return undefined;
};

View File

@@ -0,0 +1,125 @@
import { IncomingMessage } from 'http';
import { ServerResponse } from 'http';
import { GetServerSidePropsContext } from 'next';
import { SSRConfig } from 'next-i18next';
import { ParsedUrlQuery } from 'querystring';
import { describe, expect, it, vitest } from 'vitest';
import { getServerSideProps } from '~/pages/auth/login';
import * as serverAuthModule from '~/server/auth';
import * as getServerSideTranslationsModule from '../../../src/tools/server/getServerSideTranslations';
vitest.mock('./../../server/auth.ts', () => ({
getServerAuthSession: () => null,
}));
vitest.mock('./../../tools/server/getServerSideTranslations.ts', () => ({
getServerSideTranslations: () => null,
}));
describe('login page', () => {
it('getServerSideProps should return null redirectAfterLogin when no query value', async () => {
// arrange
vitest.spyOn(serverAuthModule, 'getServerAuthSession').mockReturnValue(Promise.resolve(null));
vitest
.spyOn(getServerSideTranslationsModule, 'getServerSideTranslations')
.mockReturnValue(Promise.resolve({ _i18Next: 'hello' } as unknown as SSRConfig));
// act
const response = await getServerSideProps({
query: {},
locale: 'de-DE',
req: {},
res: {},
} as GetServerSidePropsContext<ParsedUrlQuery>);
// assert
expect(response).toStrictEqual({
props: {
redirectAfterLogin: null,
_i18Next: 'hello',
},
});
expect(serverAuthModule.getServerAuthSession).toHaveBeenCalledOnce();
expect(getServerSideTranslationsModule.getServerSideTranslations).toHaveBeenCalledOnce();
expect(getServerSideTranslationsModule.getServerSideTranslations).toHaveBeenCalledWith(
['authentication/login'],
'de-DE',
{},
{}
);
});
it('getServerSideProps should return url when redirectAfterLogin is local and valid', async () => {
// arrange
vitest.spyOn(serverAuthModule, 'getServerAuthSession').mockReturnValue(Promise.resolve(null));
vitest
.spyOn(getServerSideTranslationsModule, 'getServerSideTranslations')
.mockReturnValue(Promise.resolve({ _i18Next: 'hello' } as unknown as SSRConfig));
// act
const response = await getServerSideProps({
query: {
redirectAfterLogin: '/manage/users/create',
},
locale: 'de-DE',
req: {} as IncomingMessage & { cookies: Partial<{ [key: string]: string }> },
res: {} as ServerResponse<IncomingMessage>,
resolvedUrl: '/auth/login',
} as GetServerSidePropsContext<ParsedUrlQuery>);
// assert
expect(response).toStrictEqual({
props: {
redirectAfterLogin: '/manage/users/create',
_i18Next: 'hello',
},
});
expect(serverAuthModule.getServerAuthSession).toHaveBeenCalledOnce();
expect(getServerSideTranslationsModule.getServerSideTranslations).toHaveBeenCalledOnce();
expect(getServerSideTranslationsModule.getServerSideTranslations).toHaveBeenCalledWith(
['authentication/login'],
'de-DE',
{},
{}
);
});
it('getServerSideProps should return null when url does not match regex', async () => {
// arrange
vitest.spyOn(serverAuthModule, 'getServerAuthSession').mockReturnValue(Promise.resolve(null));
vitest
.spyOn(getServerSideTranslationsModule, 'getServerSideTranslations')
.mockReturnValue(Promise.resolve({ _i18Next: 'hello' } as unknown as SSRConfig));
// act
const response = await getServerSideProps({
query: {
redirectAfterLogin: "data:text/html,<script>alert('hi');</script>",
},
locale: 'de-DE',
req: {} as IncomingMessage & { cookies: Partial<{ [key: string]: string }> },
res: {} as ServerResponse<IncomingMessage>,
resolvedUrl: '/auth/login',
} as GetServerSidePropsContext<ParsedUrlQuery>);
// assert
expect(response).toStrictEqual({
props: {
redirectAfterLogin: null,
_i18Next: 'hello',
},
});
expect(serverAuthModule.getServerAuthSession).toHaveBeenCalledOnce();
expect(getServerSideTranslationsModule.getServerSideTranslations).toHaveBeenCalledOnce();
expect(getServerSideTranslationsModule.getServerSideTranslations).toHaveBeenCalledWith(
['authentication/login'],
'de-DE',
{},
{}
);
});
});

View File

@@ -158,12 +158,11 @@ describe('[slug] page', () => {
// assert // assert
expect(response).toEqual({ expect(response).toEqual({
notFound: true, redirect: {
props: { destination: "/auth/login?redirectAfterLogin=/board/my-authentication-board",
primaryColor: 'red', permanent: false
secondaryColor: 'blue',
primaryShade: 'green',
}, },
props: {},
}); });
expect(serverAuthModule.getServerAuthSession).toHaveBeenCalledOnce(); expect(serverAuthModule.getServerAuthSession).toHaveBeenCalledOnce();
expect(configExistsModule.configExists).toHaveBeenCalledOnce(); expect(configExistsModule.configExists).toHaveBeenCalledOnce();

View File

@@ -9,7 +9,7 @@ export default defineConfig({
environment: 'happy-dom', environment: 'happy-dom',
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['html'], reporter: ['html', 'json-summary', 'json'],
all: true, all: true,
exclude: ['.next/', '.yarn/', 'data/'], exclude: ['.next/', '.yarn/', 'data/'],
}, },

View File

@@ -3231,6 +3231,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitest/coverage-v8@npm:^0.34.5":
version: 0.34.5
resolution: "@vitest/coverage-v8@npm:0.34.5"
dependencies:
"@ampproject/remapping": ^2.2.1
"@bcoe/v8-coverage": ^0.2.3
istanbul-lib-coverage: ^3.2.0
istanbul-lib-report: ^3.0.1
istanbul-lib-source-maps: ^4.0.1
istanbul-reports: ^3.1.5
magic-string: ^0.30.1
picocolors: ^1.0.0
std-env: ^3.3.3
test-exclude: ^6.0.0
v8-to-istanbul: ^9.1.0
peerDependencies:
vitest: ">=0.32.0 <1"
checksum: 6ffbcbd0d992b535c7fbc5d8ad82f8939e11786e28a899d1b2ef60f51c192063738d60b0037f7c5fec5545130f671b408bc5f20432abb280bf9788214467c475
languageName: node
linkType: hard
"@vitest/expect@npm:0.33.0": "@vitest/expect@npm:0.33.0":
version: 0.33.0 version: 0.33.0
resolution: "@vitest/expect@npm:0.33.0" resolution: "@vitest/expect@npm:0.33.0"
@@ -6948,6 +6969,7 @@ __metadata:
"@typescript-eslint/parser": ^6.0.0 "@typescript-eslint/parser": ^6.0.0
"@vitejs/plugin-react": ^4.0.0 "@vitejs/plugin-react": ^4.0.0
"@vitest/coverage-c8": ^0.33.0 "@vitest/coverage-c8": ^0.33.0
"@vitest/coverage-v8": ^0.34.5
"@vitest/ui": ^0.34.4 "@vitest/ui": ^0.34.4
axios: ^1.0.0 axios: ^1.0.0
bcryptjs: ^2.4.3 bcryptjs: ^2.4.3
@@ -7729,7 +7751,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"istanbul-lib-report@npm:^3.0.0": "istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1":
version: 3.0.1 version: 3.0.1
resolution: "istanbul-lib-report@npm:3.0.1" resolution: "istanbul-lib-report@npm:3.0.1"
dependencies: dependencies:
@@ -7740,7 +7762,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"istanbul-reports@npm:^3.1.4": "istanbul-lib-source-maps@npm:^4.0.1":
version: 4.0.1
resolution: "istanbul-lib-source-maps@npm:4.0.1"
dependencies:
debug: ^4.1.1
istanbul-lib-coverage: ^3.0.0
source-map: ^0.6.1
checksum: 21ad3df45db4b81852b662b8d4161f6446cd250c1ddc70ef96a585e2e85c26ed7cd9c2a396a71533cfb981d1a645508bc9618cae431e55d01a0628e7dec62ef2
languageName: node
linkType: hard
"istanbul-reports@npm:^3.1.4, istanbul-reports@npm:^3.1.5":
version: 3.1.6 version: 3.1.6
resolution: "istanbul-reports@npm:3.1.6" resolution: "istanbul-reports@npm:3.1.6"
dependencies: dependencies:
@@ -10904,6 +10937,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"source-map@npm:^0.6.1":
version: 0.6.1
resolution: "source-map@npm:0.6.1"
checksum: 59ce8640cf3f3124f64ac289012c2b8bd377c238e316fb323ea22fbfe83da07d81e000071d7242cad7a23cd91c7de98e4df8830ec3f133cb6133a5f6e9f67bc2
languageName: node
linkType: hard
"split-ca@npm:^1.0.1": "split-ca@npm:^1.0.1":
version: 1.0.1 version: 1.0.1
resolution: "split-ca@npm:1.0.1" resolution: "split-ca@npm:1.0.1"
@@ -12134,7 +12174,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"v8-to-istanbul@npm:^9.0.0": "v8-to-istanbul@npm:^9.0.0, v8-to-istanbul@npm:^9.1.0":
version: 9.1.0 version: 9.1.0
resolution: "v8-to-istanbul@npm:9.1.0" resolution: "v8-to-istanbul@npm:9.1.0"
dependencies: dependencies: