feat: add credentials authentication (#1)

This commit is contained in:
Meier Lukas
2023-12-10 17:12:20 +01:00
committed by GitHub
parent 41e54d940b
commit 3cedb7fba5
53 changed files with 890 additions and 2105 deletions

View File

@@ -14,6 +14,7 @@
"dependencies": {
"@alparr/auth": "workspace:^0.1.0",
"@alparr/db": "workspace:^0.1.0",
"@alparr/validation": "workspace:^0.1.0",
"@trpc/client": "next",
"@trpc/server": "next",
"superjson": "2.2.1",
@@ -34,4 +35,4 @@
]
},
"prettier": "@alparr/prettier-config"
}
}

View File

@@ -1,10 +1,8 @@
import { authRouter } from "./router/auth";
import { postRouter } from "./router/post";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
auth: authRouter,
post: postRouter,
user: userRouter,
});
// export type definition of API

View File

@@ -1,11 +0,0 @@
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const authRouter = createTRPCRouter({
getSession: publicProcedure.query(({ ctx }) => {
return ctx.session;
}),
getSecretMessage: protectedProcedure.query(() => {
// testing type validation of overridden next-auth Session in @alparr/auth package
return "you can see this secret message!";
}),
});

View File

@@ -1,40 +0,0 @@
import { z } from "zod";
import { desc, eq, schema } from "@alparr/db";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
export const postRouter = createTRPCRouter({
all: publicProcedure.query(({ ctx }) => {
// return ctx.db.select().from(schema.post).orderBy(desc(schema.post.id));
return ctx.db.query.post.findMany({ orderBy: desc(schema.post.id) });
}),
byId: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ ctx, input }) => {
// return ctx.db
// .select()
// .from(schema.post)
// .where(eq(schema.post.id, input.id));
return ctx.db.query.post.findFirst({
where: eq(schema.post.id, input.id),
});
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1),
content: z.string().min(1),
}),
)
.mutation(({ ctx, input }) => {
return ctx.db.insert(schema.post).values(input);
}),
delete: protectedProcedure.input(z.number()).mutation(({ ctx, input }) => {
return ctx.db.delete(schema.post).where(eq(schema.post.id, input));
}),
});

View File

@@ -0,0 +1,39 @@
import "server-only";
import { TRPCError } from "@trpc/server";
import { createSalt, hashPassword } from "@alparr/auth";
import { createId, schema } from "@alparr/db";
import { initUserSchema } from "@alparr/validation";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const userRouter = createTRPCRouter({
initUser: publicProcedure
.input(initUserSchema)
.mutation(async ({ ctx, input }) => {
const firstUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
});
if (firstUser) {
throw new TRPCError({
code: "FORBIDDEN",
message: "User already exists",
});
}
const salt = await createSalt();
const hashedPassword = await hashPassword(input.password, salt);
const userId = createId();
await ctx.db.insert(schema.users).values({
id: userId,
name: input.username,
password: hashedPassword,
salt,
});
}),
});

1
packages/auth/client.ts Normal file
View File

@@ -0,0 +1 @@
export { signIn, signOut } from "next-auth/react";

View File

@@ -0,0 +1,76 @@
import { cookies } from "next/headers";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { db } from "@alparr/db";
import { credentialsConfiguration } from "./providers/credentials";
import { EmptyNextAuthProvider } from "./providers/empty";
import { expireDateAfter, generateSessionToken } from "./session";
const adapter = DrizzleAdapter(db);
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
export const createConfiguration = (isCredentialsRequest: boolean) =>
NextAuth({
adapter,
providers: [Credentials(credentialsConfiguration), EmptyNextAuthProvider()],
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
name: user.name,
},
}),
signIn: async ({ user }) => {
if (!isCredentialsRequest) return true;
if (!user) return true;
const sessionToken = generateSessionToken();
const sessionExpiry = expireDateAfter(sessionMaxAgeInSeconds);
// https://github.com/nextauthjs/next-auth/issues/6106
if (!adapter?.createSession) {
return false;
}
await adapter.createSession({
sessionToken: sessionToken,
userId: user.id,
expires: sessionExpiry,
});
cookies().set("next-auth.session-token", sessionToken, {
path: "/",
expires: sessionExpiry,
httpOnly: true,
sameSite: "lax",
secure: true,
});
return true;
},
},
session: {
strategy: "database",
maxAge: sessionMaxAgeInSeconds,
},
pages: {
signIn: "/auth/login",
error: "/auth/login",
},
jwt: {
encode() {
const cookie = cookies().get("next-auth.session-token")?.value;
return cookie ?? "";
},
decode() {
return null;
},
},
});

View File

@@ -3,8 +3,6 @@ import { z } from "zod";
export const env = createEnv({
server: {
AUTH_DISCORD_ID: z.string().min(1),
AUTH_DISCORD_SECRET: z.string().min(1),
AUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
@@ -19,8 +17,6 @@ export const env = createEnv({
},
client: {},
runtimeEnv: {
AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
AUTH_SECRET: process.env.AUTH_SECRET,
AUTH_URL: process.env.AUTH_URL,
},

View File

@@ -1,12 +1,6 @@
/* eslint-disable @typescript-eslint/unbound-method */
/* @see https://github.com/nextauthjs/next-auth/pull/8932 */
import Discord from "@auth/core/providers/discord";
import type { DefaultSession } from "@auth/core/types";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import NextAuth from "next-auth";
import { db, tableCreator } from "@alparr/db";
import { createConfiguration } from "./configuration";
export type { Session } from "next-auth";
@@ -18,21 +12,8 @@ declare module "next-auth" {
}
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
adapter: DrizzleAdapter(db, tableCreator),
providers: [Discord],
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
},
});
export * from "./security";
export const createHandlers = (isCredentialsRequest: boolean) =>
createConfiguration(isCredentialsRequest);
export const { auth } = createConfiguration(false);

View File

@@ -16,6 +16,8 @@
"@auth/core": "^0.18.4",
"@auth/drizzle-adapter": "^0.3.9",
"@t3-oss/env-nextjs": "^0.7.1",
"bcrypt": "^5.1.1",
"cookies": "^0.8.0",
"next": "^14.0.3",
"next-auth": "5.0.0-beta.4",
"react": "18.2.0",
@@ -26,6 +28,9 @@
"@alparr/eslint-config": "workspace:^0.2.0",
"@alparr/prettier-config": "workspace:^0.1.0",
"@alparr/tsconfig": "workspace:^0.1.0",
"@alparr/validation": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"@types/cookies": "0.7.10",
"eslint": "^8.53.0",
"prettier": "^3.1.0",
"typescript": "^5.3.3"
@@ -37,4 +42,4 @@
]
},
"prettier": "@alparr/prettier-config"
}
}

View File

@@ -0,0 +1,49 @@
import type Credentials from "@auth/core/providers/credentials";
import bcrypt from "bcrypt";
import { db, eq } from "@alparr/db";
import { users } from "@alparr/db/schema/sqlite";
import { signInSchema } from "@alparr/validation";
type CredentialsConfiguration = Parameters<typeof Credentials>[0];
export const credentialsConfiguration = {
type: "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({
where: eq(users.name, data.name),
});
if (!user?.password) {
return null;
}
console.log(`user ${user.name} is trying to log in. checking password...`);
const isValidPassword = await bcrypt.compare(data.password, user.password);
if (!isValidPassword) {
console.log(`password for user ${user.name} was incorrect`);
return null;
}
console.log(`user ${user.name} successfully authorized`);
return {
id: user.id,
name: user.name,
};
},
} satisfies CredentialsConfiguration;

View File

@@ -0,0 +1,16 @@
import type { OAuthConfig } from "next-auth/providers";
export function EmptyNextAuthProvider(): OAuthConfig<unknown> {
return {
id: "empty",
name: "Empty",
type: "oauth",
profile: () => {
throw new Error(
"EmptyNextAuthProvider can not be used and is only a placeholder because credentials authentication can not be used as session authentication without additional providers.",
);
},
issuer: "empty",
authorization: new URL("https://example.empty"),
};
}

View File

@@ -0,0 +1,9 @@
import bcrypt from "bcrypt";
export const createSalt = async () => {
return bcrypt.genSalt(10);
};
export const hashPassword = async (password: string, salt: string) => {
return bcrypt.hash(password, salt);
};

9
packages/auth/session.ts Normal file
View File

@@ -0,0 +1,9 @@
import { randomUUID } from "crypto";
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);
};
export const generateSessionToken = () => {
return randomUUID();
};

View File

@@ -1,9 +1,8 @@
import Database from 'better-sqlite3';
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as sqliteSchema from "./schema/sqlite";
export const schema = sqliteSchema;
export * from "drizzle-orm";
@@ -11,3 +10,5 @@ export * from "drizzle-orm";
const sqlite = new Database(process.env.DB_URL!);
export const db = drizzle(sqlite, { schema });
export { createId } from "@paralleldrive/cuid2";

View File

@@ -14,7 +14,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@types/better-sqlite3": "^7.6.8",
"@paralleldrive/cuid2": "^2.2.2",
"better-sqlite3": "^9.2.2",
"drizzle-orm": "^0.29.1"
},
@@ -22,6 +22,7 @@
"@alparr/eslint-config": "workspace:^0.2.0",
"@alparr/prettier-config": "workspace:^0.1.0",
"@alparr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "7.6.8",
"dotenv-cli": "^7.3.0",
"drizzle-kit": "^0.20.6",
"eslint": "^8.53.0",
@@ -35,4 +36,4 @@
]
},
"prettier": "@alparr/prettier-config"
}
}

View File

@@ -1,65 +1,73 @@
import type { AdapterAccount } from '@auth/core/adapters';
import type { InferSelectModel } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import type { AdapterAccount } from "@auth/core/adapters";
import type { InferSelectModel } from "drizzle-orm";
import { relations } from "drizzle-orm";
import {
index,
integer,
primaryKey,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
export const users = sqliteTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email'),
emailVerified: integer('emailVerified', { mode: 'timestamp_ms' }),
image: text('image'),
password: text('password'),
salt: text('salt'),
export const users = sqliteTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name"),
email: text("email"),
emailVerified: integer("emailVerified", { mode: "timestamp_ms" }),
image: text("image"),
password: text("password"),
salt: text("salt"),
});
export const accounts = sqliteTable(
'account',
"account",
{
userId: text('userId')
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccount['type']>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => ({
compoundKey: primaryKey({ columns: [account.provider, account.providerAccountId] }),
userIdIdx: index('userId_idx').on(account.userId),
})
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
userIdIdx: index("userId_idx").on(account.userId),
}),
);
export const sessions = sqliteTable(
'session',
"session",
{
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
.references(() => users.id, { onDelete: "cascade" }),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
},
(session) => ({
userIdIdx: index('user_id_idx').on(session.userId),
})
userIdIdx: index("user_id_idx").on(session.userId),
}),
);
export const verificationTokens = sqliteTable(
'verificationToken',
"verificationToken",
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
}),
);
export const accountRelations = relations(accounts, ({ one }) => ({

View File

@@ -1 +1 @@
export * from './src';
export * from "./src";

View File

@@ -35,4 +35,4 @@
"dependencies": {
"@mantine/core": "^7.3.1"
}
}
}

View File

@@ -1,7 +1,7 @@
import type { MantineProviderProps } from "@mantine/core";
import { theme } from "./theme";
export const uiConfiguration = ({
theme,
}) satisfies MantineProviderProps;
export const uiConfiguration = {
theme,
} satisfies MantineProviderProps;

View File

@@ -1,11 +1,12 @@
import { createTheme } from "@mantine/core";
import { primaryColor } from "./theme/colors/primary";
import { secondaryColor } from "./theme/colors/secondary";
export const theme = createTheme({
colors: {
primaryColor,
secondaryColor,
},
primaryColor: "primaryColor",
});
colors: {
primaryColor,
secondaryColor,
},
primaryColor: "primaryColor",
});

View File

@@ -1,14 +1,14 @@
import type { MantineColorsTuple } from "@mantine/core";
export const primaryColor: MantineColorsTuple = [
'#eafbf0',
'#ddefe3',
'#bedcc7',
'#9bc8aa',
'#7eb892',
'#6bad81',
'#60a878',
'#509265',
'#438359',
'#35724a'
"#eafbf0",
"#ddefe3",
"#bedcc7",
"#9bc8aa",
"#7eb892",
"#6bad81",
"#60a878",
"#509265",
"#438359",
"#35724a",
];

View File

@@ -1,14 +1,14 @@
import type { MantineColorsTuple } from "@mantine/core";
export const secondaryColor: MantineColorsTuple = [
'#e6f7ff',
'#d9e8f6',
'#b6cde6',
'#90b2d4',
'#6f9ac5',
'#5a8bbd',
'#4d84ba',
'#3d71a4',
'#326595',
'#205885'
];
"#e6f7ff",
"#d9e8f6",
"#b6cde6",
"#90b2d4",
"#6f9ac5",
"#5a8bbd",
"#4d84ba",
"#3d71a4",
"#326595",
"#205885",
];

View File

@@ -0,0 +1 @@
export * from "./src";

View File

@@ -0,0 +1,38 @@
{
"name": "@alparr/validation",
"private": true,
"version": "0.1.0",
"exports": {
".": "./index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"license": "MIT",
"scripts": {
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@alparr/eslint-config": "workspace:^0.2.0",
"@alparr/prettier-config": "workspace:^0.1.0",
"@alparr/tsconfig": "workspace:^0.1.0",
"eslint": "^8.53.0",
"typescript": "^5.3.3"
},
"eslintConfig": {
"extends": [
"@alparr/eslint-config/base"
]
},
"prettier": "@alparr/prettier-config",
"dependencies": {
"zod": "^3.22.2"
}
}

View File

@@ -0,0 +1 @@
export * from "./user";

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
const usernameSchema = z.string().min(3).max(255);
const passwordSchema = z.string().min(8).max(255);
export const initUserSchema = z
.object({
username: usernameSchema,
password: passwordSchema,
repeatPassword: z.string(),
})
.refine((data) => data.password === data.repeatPassword, {
path: ["repeatPassword"],
message: "Passwords do not match",
});
export const signInSchema = z.object({
name: z.string(),
password: z.string(),
});

View File

@@ -0,0 +1,8 @@
{
"extends": "@alparr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}