feat: add ldap and oidc support (#1497)
Co-authored-by: Thomas Camlong <49837342+ajnart@users.noreply.github.com> Co-authored-by: Tagaishi <Tagaishi@hotmail.ch>
This commit is contained in:
@@ -111,7 +111,7 @@ export const ReviewInputStep = ({ values, prevStep, nextStep }: ReviewInputStepP
|
||||
password: values.security.password,
|
||||
email: values.account.eMail === '' ? undefined : values.account.eMail,
|
||||
});
|
||||
umami.track('Create user', { username: values.account.username});
|
||||
umami.track('Create user', { username: values.account.username });
|
||||
}}
|
||||
loading={isLoading}
|
||||
rightIcon={<IconCheck size="1rem" />}
|
||||
|
||||
@@ -57,9 +57,12 @@ export const StepCreateAccount = ({
|
||||
Create your administrator account
|
||||
</Title>
|
||||
<Text>
|
||||
Your administrator account <b>must be secure</b>, that's why we have so many rules surrounding it.
|
||||
<br/>Try not to make it adminadmin this time...
|
||||
<br/>Note: these password requirements <b>are not forced</b>, they are just recommendations.
|
||||
Your administrator account <b>must be secure</b>, that's why we have so many rules
|
||||
surrounding it.
|
||||
<br />
|
||||
Try not to make it adminadmin this time...
|
||||
<br />
|
||||
Note: these password requirements <b>are not forced</b>, they are just recommendations.
|
||||
</Text>
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
|
||||
60
src/env.js
60
src/env.js
@@ -1,6 +1,14 @@
|
||||
const { z } = require('zod');
|
||||
const { createEnv } = require('@t3-oss/env-nextjs');
|
||||
|
||||
const trueStrings = ["1", "t", "T", "TRUE", "true", "True"];
|
||||
const falseStrings = ["0", "f", "F", "FALSE", "false", "False"];
|
||||
|
||||
const zodParsedBoolean = () => z
|
||||
.enum([...trueStrings, ...falseStrings])
|
||||
.default("false")
|
||||
.transform((value) => trueStrings.includes(value))
|
||||
|
||||
const portSchema = z
|
||||
.string()
|
||||
.regex(/\d*/)
|
||||
@@ -8,6 +16,8 @@ const portSchema = z
|
||||
.optional();
|
||||
const envSchema = z.enum(['development', 'test', 'production']);
|
||||
|
||||
const authProviders = process.env.AUTH_PROVIDER?.replaceAll(' ', '').split(',') || ['credentials'];
|
||||
|
||||
const env = createEnv({
|
||||
/**
|
||||
* Specify your server-side environment variables schema here. This way you can ensure the app
|
||||
@@ -28,6 +38,37 @@ const env = createEnv({
|
||||
DOCKER_PORT: portSchema,
|
||||
DEMO_MODE: z.string().optional(),
|
||||
HOSTNAME: z.string().optional(),
|
||||
|
||||
// Authentication
|
||||
AUTH_PROVIDER: z.string().default('credentials').transform(providers => providers.replaceAll(' ', '').split(',')),
|
||||
// LDAP
|
||||
...(authProviders.includes('ldap')
|
||||
? {
|
||||
AUTH_LDAP_URI: z.string().url(),
|
||||
AUTH_LDAP_BIND_DN: z.string(),
|
||||
AUTH_LDAP_BIND_PASSWORD: z.string(),
|
||||
AUTH_LDAP_BASE: z.string(),
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: z.string().default('uid'),
|
||||
AUTH_LDAP_GROUP_CLASS: z.string().default('groupOfUniqueNames'),
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: z.string().default('member'),
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: z.string().default('dn'),
|
||||
AUTH_LDAP_ADMIN_GROUP: z.string().default('admin'),
|
||||
AUTH_LDAP_OWNER_GROUP: z.string().default('admin'),
|
||||
}
|
||||
: {}),
|
||||
// OIDC
|
||||
...(authProviders.includes('oidc')
|
||||
? {
|
||||
AUTH_OIDC_CLIENT_ID: z.string(),
|
||||
AUTH_OIDC_CLIENT_SECRET: z.string(),
|
||||
AUTH_OIDC_URI: z.string().url(),
|
||||
// Custom Display name, defaults to OIDC
|
||||
AUTH_OIDC_CLIENT_NAME: z.string().default('OIDC'),
|
||||
AUTH_OIDC_ADMIN_GROUP: z.string().default('admin'),
|
||||
AUTH_OIDC_OWNER_GROUP: z.string().default('admin'),
|
||||
AUTH_OIDC_AUTO_LOGIN: zodParsedBoolean()
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -64,6 +105,25 @@ const env = createEnv({
|
||||
NEXT_PUBLIC_PORT: process.env.PORT,
|
||||
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
|
||||
HOSTNAME: process.env.HOSTNAME,
|
||||
AUTH_PROVIDER: process.env.AUTH_PROVIDER,
|
||||
AUTH_LDAP_URI: process.env.AUTH_LDAP_URI,
|
||||
AUTH_LDAP_BIND_DN: process.env.AUTH_LDAP_BIND_DN,
|
||||
AUTH_LDAP_BIND_PASSWORD: process.env.AUTH_LDAP_BIND_PASSWORD,
|
||||
AUTH_LDAP_BASE: process.env.AUTH_LDAP_BASE,
|
||||
AUTH_LDAP_USERNAME_ATTRIBUTE: process.env.AUTH_LDAP_USERNAME_ATTRIBUTE,
|
||||
AUTH_LDAP_GROUP_CLASS: process.env.AUTH_LDAP_GROUP_CLASS,
|
||||
AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE,
|
||||
AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE: process.env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE,
|
||||
AUTH_LDAP_ADMIN_GROUP: process.env.AUTH_LDAP_ADMIN_GROUP,
|
||||
AUTH_LDAP_OWNER_GROUP: process.env.AUTH_LDAP_OWNER_GROUP,
|
||||
AUTH_OIDC_CLIENT_ID: process.env.AUTH_OIDC_CLIENT_ID,
|
||||
AUTH_OIDC_CLIENT_SECRET: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||
AUTH_OIDC_URI: process.env.AUTH_OIDC_URI,
|
||||
AUTH_OIDC_CLIENT_NAME: process.env.AUTH_OIDC_CLIENT_NAME,
|
||||
AUTH_OIDC_GROUP_CLAIM: process.env.AUTH_OIDC_GROUP_CLAIM,
|
||||
AUTH_OIDC_ADMIN_GROUP: process.env.AUTH_OIDC_ADMIN_GROUP,
|
||||
AUTH_OIDC_OWNER_GROUP: process.env.AUTH_OIDC_OWNER_GROUP,
|
||||
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
},
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
|
||||
@@ -10,6 +10,7 @@ const skippedUrls = [
|
||||
'/favicon.ico',
|
||||
'/404',
|
||||
'/pages/_app',
|
||||
'/auth/login',
|
||||
'/imgs/',
|
||||
];
|
||||
|
||||
@@ -29,12 +30,15 @@ export async function middleware(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Do not redirect if there are users in the database
|
||||
if (cachedUserCount > 0) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (cachedUserCount > 0 || !(await shouldRedirectToOnboard())) {
|
||||
// redirect to login if not logged in
|
||||
// not working, should work in next-auth 5
|
||||
// @see https://github.com/nextauthjs/next-auth/pull/7443
|
||||
|
||||
// Do not redirect if there are users in the database
|
||||
if (!(await shouldRedirectToOnboard())) {
|
||||
// const session = await getServerSession();
|
||||
// if (!session?.user) {
|
||||
// return NextResponse.redirect(getUrl(req) + '/auth/login')
|
||||
// }
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -125,8 +125,7 @@ export default function AuthInvitePage() {
|
||||
withAsterisk
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<Card
|
||||
>
|
||||
<Card>
|
||||
<PasswordRequirements value={form.values.password} />
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { Alert, Button, Card, Flex, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Flex,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { GetServerSidePropsContext, InferGetServerSidePropsType } from 'next';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
@@ -17,8 +28,13 @@ import { getServerSideTranslations } from '~/tools/server/getServerSideTranslati
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
import { signInSchema } from '~/validations/user';
|
||||
|
||||
const signInSchemaWithProvider = signInSchema.extend({ provider: z.string() });
|
||||
|
||||
export default function LoginPage({
|
||||
redirectAfterLogin,
|
||||
providers,
|
||||
oidcProviderName,
|
||||
oidcAutoLogin,
|
||||
isDemo,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { t } = useTranslation('authentication/login');
|
||||
@@ -27,16 +43,18 @@ export default function LoginPage({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const form = useForm<z.infer<typeof signInSchema>>({
|
||||
const hasCredentialsInput = providers.includes('credentials') || providers.includes('ldap');
|
||||
|
||||
const form = useForm<z.infer<typeof signInSchemaWithProvider>>({
|
||||
validateInputOnChange: true,
|
||||
validateInputOnBlur: true,
|
||||
validate: i18nZodResolver(signInSchema),
|
||||
validate: i18nZodResolver(signInSchemaWithProvider),
|
||||
});
|
||||
|
||||
const handleSubmit = (values: z.infer<typeof signInSchema>) => {
|
||||
const handleSubmit = (values: z.infer<typeof signInSchemaWithProvider>) => {
|
||||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
signIn('credentials', {
|
||||
signIn(values.provider, {
|
||||
redirect: false,
|
||||
name: values.name,
|
||||
password: values.password,
|
||||
@@ -51,6 +69,10 @@ export default function LoginPage({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (oidcAutoLogin) signIn('oidc');
|
||||
}, [oidcAutoLogin]);
|
||||
|
||||
const metaTitle = `${t('metaTitle')} • Homarr`;
|
||||
|
||||
return (
|
||||
@@ -58,7 +80,6 @@ export default function LoginPage({
|
||||
<Head>
|
||||
<title>{metaTitle}</title>
|
||||
</Head>
|
||||
|
||||
<Flex h="100dvh" display="flex" w="100%" direction="column" align="center" justify="center">
|
||||
<FloatingBackground />
|
||||
<ThemeSchemeToggle pos="absolute" top={20} right={20} />
|
||||
@@ -83,51 +104,94 @@ export default function LoginPage({
|
||||
<b>demodemo</b>
|
||||
</Alert>
|
||||
)}
|
||||
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
|
||||
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
|
||||
{t('title')}
|
||||
</Title>
|
||||
{oidcAutoLogin ? (
|
||||
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
|
||||
<Text size="lg" align="center" m="md">
|
||||
Signing in with OIDC provider
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder shadow="md" p="xl" radius="md" w="90%" maw={450}>
|
||||
<Title style={{ whiteSpace: 'nowrap' }} align="center" weight={900}>
|
||||
{t('title')}
|
||||
</Title>
|
||||
|
||||
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
|
||||
{t('text')}
|
||||
</Text>
|
||||
<Text color="dimmed" size="sm" align="center" mt={5} mb="md">
|
||||
{t('text')}
|
||||
</Text>
|
||||
|
||||
{isError && (
|
||||
<Alert icon={<IconAlertTriangle size="1rem" />} color="red">
|
||||
{t('alert')}
|
||||
</Alert>
|
||||
)}
|
||||
{isError && (
|
||||
<Alert icon={<IconAlertTriangle size="1rem" />} color="red">
|
||||
{t('alert')}
|
||||
</Alert>
|
||||
)}
|
||||
{hasCredentialsInput && (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label={t('form.fields.username.label')}
|
||||
autoComplete="homarr-username"
|
||||
withAsterisk
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label={t('form.fields.username.label')}
|
||||
autoComplete="homarr-username"
|
||||
withAsterisk
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
label={t('form.fields.password.label')}
|
||||
autoComplete="homarr-password"
|
||||
withAsterisk
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
label={t('form.fields.password.label')}
|
||||
autoComplete="homarr-password"
|
||||
withAsterisk
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
{providers.includes('credentials') && (
|
||||
<Button
|
||||
mt="xs"
|
||||
variant="light"
|
||||
fullWidth
|
||||
type="submit"
|
||||
disabled={isLoading && form.values.provider != 'credentials'}
|
||||
loading={isLoading && form.values.provider == 'credentials'}
|
||||
name="credentials"
|
||||
onClick={() => form.setFieldValue('provider', 'credentials')}
|
||||
>
|
||||
{t('form.buttons.submit')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button mt="xs" variant="light" fullWidth type="submit" loading={isLoading}>
|
||||
{t('form.buttons.submit')}
|
||||
{providers.includes('ldap') && (
|
||||
<Button
|
||||
mt="xs"
|
||||
variant="light"
|
||||
fullWidth
|
||||
type="submit"
|
||||
disabled={isLoading && form.values.provider != 'ldap'}
|
||||
loading={isLoading && form.values.provider == 'ldap'}
|
||||
name="ldap"
|
||||
onClick={() => form.setFieldValue('provider', 'ldap')}
|
||||
>
|
||||
{t('form.buttons.submit')} - LDAP
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{redirectAfterLogin && (
|
||||
<Text color="dimmed" align="center" size="xs">
|
||||
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
{hasCredentialsInput && providers.includes('oidc') && (
|
||||
<Divider label="OIDC" labelPosition="center" mt="xl" mb="md" />
|
||||
)}
|
||||
{providers.includes('oidc') && (
|
||||
<Button mt="xs" variant="light" fullWidth onClick={() => signIn('oidc')}>
|
||||
{t('form.buttons.submit')} - {oidcProviderName}
|
||||
</Button>
|
||||
|
||||
{redirectAfterLogin && (
|
||||
<Text color="dimmed" align="center" size="xs">
|
||||
{t('form.afterLoginRedirection', { url: redirectAfterLogin })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Flex>
|
||||
</>
|
||||
@@ -136,7 +200,12 @@ export default function LoginPage({
|
||||
|
||||
const regexExp = /^\/{1}[A-Za-z\/]*$/;
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async ({ locale, req, res, query }) => {
|
||||
export const getServerSideProps = async ({
|
||||
locale,
|
||||
req,
|
||||
res,
|
||||
query,
|
||||
}: GetServerSidePropsContext) => {
|
||||
const session = await getServerAuthSession({ req, res });
|
||||
|
||||
const zodResult = await z
|
||||
@@ -159,6 +228,9 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res,
|
||||
props: {
|
||||
...(await getServerSideTranslations(['authentication/login'], locale, req, res)),
|
||||
redirectAfterLogin,
|
||||
providers: env.AUTH_PROVIDER,
|
||||
oidcProviderName: env.AUTH_OIDC_CLIENT_NAME || null,
|
||||
oidcAutoLogin: env.AUTH_OIDC_AUTO_LOGIN || null,
|
||||
isDemo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,57 +1,17 @@
|
||||
import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import Consola from 'consola';
|
||||
import Cookies from 'cookies';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next';
|
||||
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
|
||||
import { type NextAuthOptions, getServerSession } from 'next-auth';
|
||||
import { Adapter } from 'next-auth/adapters';
|
||||
import { decode, encode } from 'next-auth/jwt';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { adapter, onCreateUser, providers } from '~/utils/auth';
|
||||
import EmptyNextAuthProvider from '~/utils/empty-provider';
|
||||
import { fromDate, generateSessionToken } from '~/utils/session';
|
||||
import { colorSchemeParser, signInSchema } from '~/validations/user';
|
||||
import { colorSchemeParser } from '~/validations/user';
|
||||
|
||||
import { db } from './db';
|
||||
import { users } from './db/schema';
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
* object and keep type safety.
|
||||
*
|
||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
*/
|
||||
declare module 'next-auth' {
|
||||
interface Session extends DefaultSession {
|
||||
user: DefaultSession['user'] & {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
colorScheme: 'light' | 'dark' | 'environment';
|
||||
autoFocusSearch: boolean;
|
||||
language: string;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
isAdmin: boolean;
|
||||
colorScheme: 'light' | 'dark' | 'environment';
|
||||
autoFocusSearch: boolean;
|
||||
language: string;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = DrizzleAdapter(db);
|
||||
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
/**
|
||||
@@ -63,6 +23,9 @@ export const constructAuthOptions = (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
): NextAuthOptions => ({
|
||||
events: {
|
||||
createUser: onCreateUser,
|
||||
},
|
||||
callbacks: {
|
||||
async session({ session, user }) {
|
||||
if (session.user) {
|
||||
@@ -133,58 +96,7 @@ export const constructAuthOptions = (
|
||||
error: '/auth/login',
|
||||
},
|
||||
adapter: adapter as Adapter,
|
||||
providers: [
|
||||
Credentials({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
name: {
|
||||
label: 'Username',
|
||||
type: 'text',
|
||||
},
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const data = await signInSchema.parseAsync(credentials);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
with: {
|
||||
settings: {
|
||||
columns: {
|
||||
colorScheme: true,
|
||||
language: true,
|
||||
autoFocusSearch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(users.name, data.name),
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Consola.log(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(data.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
Consola.log(`password for user ${user.name} was incorrect`);
|
||||
return null;
|
||||
}
|
||||
|
||||
Consola.log(`user ${user.name} successfully authorized`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isAdmin: false,
|
||||
colorScheme: colorSchemeParser.parse(user.settings?.colorScheme),
|
||||
language: user.settings?.language ?? 'en',
|
||||
autoFocusSearch: user.settings?.autoFocusSearch ?? false,
|
||||
};
|
||||
},
|
||||
}),
|
||||
EmptyNextAuthProvider(),
|
||||
],
|
||||
providers: [...providers, EmptyNextAuthProvider()],
|
||||
jwt: {
|
||||
async encode(params) {
|
||||
if (!isCredentialsRequest(req)) {
|
||||
@@ -207,10 +119,12 @@ export const constructAuthOptions = (
|
||||
});
|
||||
|
||||
const isCredentialsRequest = (req: NextApiRequest): boolean => {
|
||||
const nextAuthQueryParams = req.query.nextauth as ['callback', 'credentials'];
|
||||
const nextAuthQueryParams = req.query.nextauth as string[];
|
||||
return (
|
||||
nextAuthQueryParams.includes('callback') &&
|
||||
nextAuthQueryParams.includes('credentials') &&
|
||||
(nextAuthQueryParams.includes('credentials') ||
|
||||
nextAuthQueryParams.includes('ldap') ||
|
||||
nextAuthQueryParams.includes('oidc')) &&
|
||||
req.method === 'POST'
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,9 @@ import { InferSelectModel, relations } from 'drizzle-orm';
|
||||
import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { type AdapterAccount } from 'next-auth/adapters';
|
||||
|
||||
export const users = sqliteTable('user', {
|
||||
// workaround for typescript check in adapter
|
||||
// preferably add email into credential login and make email non-nullable here
|
||||
export const _users = {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name'),
|
||||
email: text('email'),
|
||||
@@ -12,7 +14,9 @@ export const users = sqliteTable('user', {
|
||||
salt: text('salt'),
|
||||
isAdmin: int('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||
isOwner: int('is_owner', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
};
|
||||
|
||||
export const users = sqliteTable('user', _users);
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
'account',
|
||||
|
||||
166
src/utils/auth/adapter.ts
Normal file
166
src/utils/auth/adapter.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import {
|
||||
BaseSQLiteDatabase,
|
||||
SQLiteTableFn,
|
||||
sqliteTable as defaultSqliteTableFn,
|
||||
text,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
import { User } from 'next-auth';
|
||||
import { Adapter, AdapterAccount } from 'next-auth/adapters';
|
||||
import { db } from '~/server/db';
|
||||
import { _users, accounts, sessions, userSettings, verificationTokens } from '~/server/db/schema';
|
||||
|
||||
// Need to modify createTables with custom schema
|
||||
const createTables = (sqliteTable: SQLiteTableFn) => ({
|
||||
users: sqliteTable('user', {
|
||||
..._users,
|
||||
email: text('email').notNull(), // workaround for typescript
|
||||
}),
|
||||
accounts,
|
||||
sessions,
|
||||
verificationTokens,
|
||||
});
|
||||
|
||||
export type DefaultSchema = ReturnType<typeof createTables>;
|
||||
|
||||
export const onCreateUser = async ({ user }: { user: User }) => {
|
||||
await db.insert(userSettings).values({
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
});
|
||||
};
|
||||
|
||||
// Keep this the same as original file @auth/drizzle-adapter/src/lib/sqlite.ts
|
||||
// only change changed return type from Adapter to "satisfies Adapter", to tell typescript createUser exists
|
||||
|
||||
export function SQLiteDrizzleAdapter(
|
||||
client: InstanceType<typeof BaseSQLiteDatabase>,
|
||||
tableFn = defaultSqliteTableFn
|
||||
) {
|
||||
const { users, accounts, sessions, verificationTokens } = createTables(tableFn);
|
||||
|
||||
return {
|
||||
createUser(data) {
|
||||
return client
|
||||
.insert(users)
|
||||
.values({ ...data, id: crypto.randomUUID() })
|
||||
.returning()
|
||||
.get();
|
||||
},
|
||||
getUser(data) {
|
||||
return client.select().from(users).where(eq(users.id, data)).get() ?? null;
|
||||
},
|
||||
getUserByEmail(data) {
|
||||
return client.select().from(users).where(eq(users.email, data)).get() ?? null;
|
||||
},
|
||||
createSession(data) {
|
||||
return client.insert(sessions).values(data).returning().get();
|
||||
},
|
||||
getSessionAndUser(data) {
|
||||
return (
|
||||
client
|
||||
.select({
|
||||
session: sessions,
|
||||
user: users,
|
||||
})
|
||||
.from(sessions)
|
||||
.where(eq(sessions.sessionToken, data))
|
||||
.innerJoin(users, eq(users.id, sessions.userId))
|
||||
.get() ?? null
|
||||
);
|
||||
},
|
||||
updateUser(data) {
|
||||
if (!data.id) {
|
||||
throw new Error('No user id.');
|
||||
}
|
||||
|
||||
return client.update(users).set(data).where(eq(users.id, data.id)).returning().get();
|
||||
},
|
||||
updateSession(data) {
|
||||
return client
|
||||
.update(sessions)
|
||||
.set(data)
|
||||
.where(eq(sessions.sessionToken, data.sessionToken))
|
||||
.returning()
|
||||
.get();
|
||||
},
|
||||
linkAccount(rawAccount) {
|
||||
const updatedAccount = client.insert(accounts).values(rawAccount).returning().get();
|
||||
|
||||
const account: AdapterAccount = {
|
||||
...updatedAccount,
|
||||
type: updatedAccount.type,
|
||||
access_token: updatedAccount.access_token ?? undefined,
|
||||
token_type: updatedAccount.token_type ?? undefined,
|
||||
id_token: updatedAccount.id_token ?? undefined,
|
||||
refresh_token: updatedAccount.refresh_token ?? undefined,
|
||||
scope: updatedAccount.scope ?? undefined,
|
||||
expires_at: updatedAccount.expires_at ?? undefined,
|
||||
session_state: updatedAccount.session_state ?? undefined,
|
||||
};
|
||||
|
||||
return account;
|
||||
},
|
||||
getUserByAccount(account) {
|
||||
const results = client
|
||||
.select()
|
||||
.from(accounts)
|
||||
.leftJoin(users, eq(users.id, accounts.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, account.provider),
|
||||
eq(accounts.providerAccountId, account.providerAccountId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
return results?.user ?? null;
|
||||
},
|
||||
deleteSession(sessionToken) {
|
||||
return (
|
||||
client.delete(sessions).where(eq(sessions.sessionToken, sessionToken)).returning().get() ??
|
||||
null
|
||||
);
|
||||
},
|
||||
createVerificationToken(token) {
|
||||
return client.insert(verificationTokens).values(token).returning().get();
|
||||
},
|
||||
useVerificationToken(token) {
|
||||
try {
|
||||
return (
|
||||
client
|
||||
.delete(verificationTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(verificationTokens.identifier, token.identifier),
|
||||
eq(verificationTokens.token, token.token)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.get() ?? null
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error('No verification token found.');
|
||||
}
|
||||
},
|
||||
deleteUser(id) {
|
||||
return client.delete(users).where(eq(users.id, id)).returning().get();
|
||||
},
|
||||
unlinkAccount(account) {
|
||||
client
|
||||
.delete(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.providerAccountId, account.providerAccountId),
|
||||
eq(accounts.provider, account.provider)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
|
||||
return undefined;
|
||||
},
|
||||
} satisfies Adapter;
|
||||
}
|
||||
|
||||
export default SQLiteDrizzleAdapter(db);
|
||||
56
src/utils/auth/credentials.ts
Normal file
56
src/utils/auth/credentials.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import Consola from 'consola';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { colorSchemeParser, signInSchema } from '~/validations/user';
|
||||
|
||||
import { db } from '../../server/db';
|
||||
import { users } from '../../server/db/schema';
|
||||
|
||||
export default Credentials({
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
name: {
|
||||
label: 'Username',
|
||||
type: 'text',
|
||||
},
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const data = await signInSchema.parseAsync(credentials);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
with: {
|
||||
settings: {
|
||||
columns: {
|
||||
colorScheme: true,
|
||||
language: true,
|
||||
autoFocusSearch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(users.name, data.name),
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Consola.log(`user ${user.name} is trying to log in. checking password...`);
|
||||
const isValidPassword = await bcrypt.compare(data.password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
Consola.log(`password for user ${user.name} was incorrect`);
|
||||
return null;
|
||||
}
|
||||
|
||||
Consola.log(`user ${user.name} successfully authorized`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isAdmin: false,
|
||||
isOwner: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
46
src/utils/auth/index.ts
Normal file
46
src/utils/auth/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { DefaultSession } from 'next-auth';
|
||||
import { CredentialsConfig, OAuthConfig } from 'next-auth/providers';
|
||||
import { env } from '~/env';
|
||||
|
||||
export { default as adapter, onCreateUser } from './adapter';
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
* object and keep type safety.
|
||||
*
|
||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
*/
|
||||
declare module 'next-auth' {
|
||||
interface Session extends DefaultSession {
|
||||
user: DefaultSession['user'] & {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
colorScheme: 'light' | 'dark' | 'environment';
|
||||
autoFocusSearch: boolean;
|
||||
language: string;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
isAdmin: boolean;
|
||||
isOwner?: boolean;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
id: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export const providers: (CredentialsConfig | OAuthConfig<any>)[] = [];
|
||||
|
||||
if (env.AUTH_PROVIDER?.includes('ldap')) providers.push((await import('./ldap')).default);
|
||||
if (env.AUTH_PROVIDER?.includes('credentials'))
|
||||
providers.push((await import('./credentials')).default);
|
||||
if (env.AUTH_PROVIDER?.includes('oidc')) providers.push((await import('./oidc')).default);
|
||||
161
src/utils/auth/ldap.ts
Normal file
161
src/utils/auth/ldap.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import Consola from 'consola';
|
||||
import ldap from 'ldapjs';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { env } from '~/env';
|
||||
import { signInSchema } from '~/validations/user';
|
||||
|
||||
import adapter, { onCreateUser } from './adapter';
|
||||
|
||||
// Helper types for infering properties of returned search type
|
||||
type AttributeConstraint = string | readonly string[] | undefined;
|
||||
|
||||
type InferrableSearchOptions<
|
||||
Attributes extends AttributeConstraint,
|
||||
ArrayAttributes extends Attributes,
|
||||
> = Omit<ldap.SearchOptions, 'attributes'> & {
|
||||
attributes?: Attributes;
|
||||
arrayAttributes?: ArrayAttributes;
|
||||
};
|
||||
|
||||
type SearchResultIndex<Attributes extends AttributeConstraint> = Attributes extends string
|
||||
? Attributes
|
||||
: Attributes extends readonly string[]
|
||||
? Attributes[number]
|
||||
: string;
|
||||
|
||||
type SearchResult<
|
||||
Attributes extends AttributeConstraint,
|
||||
ArrayAttributes extends Attributes = never,
|
||||
> = { dn: string } & Record<
|
||||
Exclude<SearchResultIndex<Attributes>, SearchResultIndex<ArrayAttributes>>,
|
||||
string
|
||||
> &
|
||||
Record<SearchResultIndex<ArrayAttributes>, string[]>;
|
||||
|
||||
const ldapLogin = (username: string, password: string) =>
|
||||
new Promise<ldap.Client>((resolve, reject) => {
|
||||
const client = ldap.createClient({
|
||||
url: env.AUTH_LDAP_URI,
|
||||
});
|
||||
client.bind(username, password, (error, res) => {
|
||||
if (error) {
|
||||
reject('Invalid username or password');
|
||||
} else {
|
||||
resolve(client);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const ldapSearch = async <
|
||||
Attributes extends AttributeConstraint,
|
||||
ArrayAttributes extends Attributes = never,
|
||||
>(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
options: InferrableSearchOptions<Attributes, ArrayAttributes>
|
||||
) =>
|
||||
new Promise<SearchResult<Attributes, ArrayAttributes>[]>((resolve, reject) => {
|
||||
client.search(base, options as ldap.SearchOptions, (err, res) => {
|
||||
const results: SearchResult<Attributes, ArrayAttributes>[] = [];
|
||||
res.on('error', (err) => {
|
||||
reject('error: ' + err.message);
|
||||
});
|
||||
res.on('searchEntry', (entry) => {
|
||||
results.push(
|
||||
entry.pojo.attributes.reduce<Record<string, string | string[]>>(
|
||||
(obj, attr) => {
|
||||
// just take first element assuming there's only one (uid, mail), unless in arrayAttributes
|
||||
obj[attr.type] = options.arrayAttributes?.includes(attr.type)
|
||||
? attr.values
|
||||
: attr.values[0];
|
||||
return obj;
|
||||
},
|
||||
{ dn: entry.pojo.objectName }
|
||||
) as SearchResult<Attributes, ArrayAttributes>
|
||||
);
|
||||
});
|
||||
res.on('end', (result) => {
|
||||
if (result?.status != 0) {
|
||||
reject(new Error('ldap search status is not 0, search failed'));
|
||||
} else {
|
||||
resolve(results);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default Credentials({
|
||||
id: 'ldap',
|
||||
name: 'LDAP',
|
||||
credentials: {
|
||||
name: { label: 'uid', type: 'text' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
try {
|
||||
const data = await signInSchema.parseAsync(credentials);
|
||||
|
||||
Consola.log(`user ${data.name} is trying to log in using LDAP. Signing in...`);
|
||||
const client = await ldapLogin(env.AUTH_LDAP_BIND_DN, env.AUTH_LDAP_BIND_PASSWORD);
|
||||
|
||||
const ldapUser = (
|
||||
await ldapSearch(client, env.AUTH_LDAP_BASE, {
|
||||
filter: `(uid=${data.name})`,
|
||||
// as const for inference
|
||||
attributes: ['uid', 'mail'] as const,
|
||||
})
|
||||
)[0];
|
||||
|
||||
await ldapLogin(ldapUser.dn, data.password).then((client) => client.destroy());
|
||||
|
||||
const userGroups = (
|
||||
await ldapSearch(client, env.AUTH_LDAP_BASE, {
|
||||
filter: `(&(objectclass=${env.AUTH_LDAP_GROUP_CLASS})(${
|
||||
env.AUTH_LDAP_GROUP_MEMBER_ATTRIBUTE
|
||||
}=${ldapUser[env.AUTH_LDAP_GROUP_MEMBER_USER_ATTRIBUTE as 'dn' | 'uid']}))`,
|
||||
// as const for inference
|
||||
attributes: 'cn',
|
||||
})
|
||||
).map((group) => group.cn);
|
||||
|
||||
client.destroy();
|
||||
|
||||
Consola.log(`user ${data.name} successfully authorized`);
|
||||
|
||||
let user = await adapter.getUserByEmail!(ldapUser.mail);
|
||||
const isAdmin = userGroups.includes(env.AUTH_LDAP_ADMIN_GROUP);
|
||||
const isOwner = userGroups.includes(env.AUTH_LDAP_OWNER_GROUP);
|
||||
|
||||
if (!user) {
|
||||
// CreateUser will create settings in event
|
||||
user = await adapter.createUser({
|
||||
name: ldapUser.uid,
|
||||
email: ldapUser.mail,
|
||||
emailVerified: new Date(), // assume ldap email is verified
|
||||
isAdmin: isAdmin,
|
||||
isOwner: isOwner,
|
||||
});
|
||||
// For some reason adapter.createUser doesn't call createUser event, needs to be called manually to create usersettings
|
||||
await onCreateUser({ user });
|
||||
} else if (user.isAdmin != isAdmin || user.isOwner != isOwner) {
|
||||
// Update roles if changed in LDAP
|
||||
Consola.log(`updating roles of user ${user.name}`);
|
||||
adapter.updateUser({
|
||||
...user,
|
||||
isAdmin,
|
||||
isOwner,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user?.id || ldapUser.dn,
|
||||
name: user?.name || ldapUser.uid,
|
||||
isAdmin: isAdmin,
|
||||
isOwner: isOwner,
|
||||
};
|
||||
} catch (error) {
|
||||
Consola.error(error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
51
src/utils/auth/oidc.ts
Normal file
51
src/utils/auth/oidc.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import Consola from 'consola';
|
||||
import { OAuthConfig } from 'next-auth/providers/oauth';
|
||||
import { env } from '~/env';
|
||||
|
||||
import adapter from './adapter';
|
||||
|
||||
type Profile = {
|
||||
sub: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string[];
|
||||
preferred_username: string;
|
||||
email_verified: boolean;
|
||||
};
|
||||
|
||||
const provider: OAuthConfig<Profile> = {
|
||||
id: 'oidc',
|
||||
name: env.AUTH_OIDC_CLIENT_NAME,
|
||||
type: 'oauth',
|
||||
clientId: env.AUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
|
||||
wellKnown: `${env.AUTH_OIDC_URI}/.well-known/openid-configuration`,
|
||||
authorization: { params: { scope: 'openid email profile groups' } },
|
||||
idToken: true,
|
||||
async profile(profile) {
|
||||
const user = await adapter.getUserByEmail!(profile.email);
|
||||
|
||||
const isAdmin = profile.groups.includes(env.AUTH_OIDC_ADMIN_GROUP);
|
||||
const isOwner = profile.groups.includes(env.AUTH_OIDC_OWNER_GROUP);
|
||||
|
||||
// check for role update
|
||||
if (user && (user.isAdmin != isAdmin || user.isOwner != isOwner)) {
|
||||
Consola.log(`updating roles of user ${user.name}`);
|
||||
adapter.updateUser({
|
||||
...user,
|
||||
isAdmin,
|
||||
isOwner,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.preferred_username,
|
||||
email: profile.email,
|
||||
isAdmin,
|
||||
isOwner,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default provider;
|
||||
Reference in New Issue
Block a user