diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..626a562 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,24 @@ +import { betterAuth } from 'better-auth' +import { prismaAdapter } from 'better-auth/adapters/prisma' +import { username } from 'better-auth/plugins' + +import prismaClient from './utils/prisma-client' +import { sendEmail } from 'utils/email' +import forgorTemplate from 'utils/forgorTemplate' + +export const auth = betterAuth({ + database: prismaAdapter(prismaClient, { provider: 'mysql' }), + user: { modelName: 'users' }, + plugins: [username()], + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true + }, + emailAndPassword: { + enabled: true, + requireEmailVerification: true, + sendResetPassword: async ({ user, url, token }, request) => { + await sendEmail(user.email, 'Reset your password', forgorTemplate.replaceAll('{{forgor_link}}', url)) + } + } +}) diff --git a/src/env.d.ts b/src/env.d.ts index e46db51..6ae6167 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -2,7 +2,7 @@ declare namespace App { // Note: 'import {} from ""' syntax does not work in .d.ts files. interface Locals { - session: import('./src/utils/session').Session | null - user: import('./src/utils/session').User | null + user: import('better-auth').User | null + session: import('better-auth').Session | null } } diff --git a/src/middleware.ts b/src/middleware.ts index 60eabb2..17ce791 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,22 +1,18 @@ +import { auth } from 'auth' import { defineMiddleware } from 'astro:middleware' -import { setSessionTokenCookie, deleteSessionTokenCookie, validateSessionToken, COOKIE_NAME } from 'utils/session' export const onRequest = defineMiddleware(async (context, next) => { - const token = context.cookies.get(COOKIE_NAME)?.value - if (!token) { + const isAuthed = await auth.api.getSession({ + headers: context.request.headers + }) + + if (isAuthed) { + context.locals.user = isAuthed.user + context.locals.session = isAuthed.session + } else { context.locals.user = null context.locals.session = null - return next() } - const { session, user } = await validateSessionToken(token) - if (session !== null) { - setSessionTokenCookie(context.cookies, token, session.expiresAt) - } else { - deleteSessionTokenCookie(context.cookies) - } - - context.locals.session = session - context.locals.user = user return next() }) diff --git a/src/pages/api/auth/[...all].ts b/src/pages/api/auth/[...all].ts new file mode 100644 index 0000000..bd8b5bf --- /dev/null +++ b/src/pages/api/auth/[...all].ts @@ -0,0 +1,6 @@ +import { auth } from 'auth' +import type { APIRoute } from 'astro' + +export const ALL: APIRoute = async (ctx) => { + return auth.handler(ctx.request) +} diff --git a/src/utils/auth-client.ts b/src/utils/auth-client.ts new file mode 100644 index 0000000..18f7a36 --- /dev/null +++ b/src/utils/auth-client.ts @@ -0,0 +1,7 @@ +import { createAuthClient } from 'better-auth/client' +import { usernameClient } from 'better-auth/client/plugins' + +export const authClient = createAuthClient({ + plugins: [usernameClient()] +}) +export const { useSession, signIn, signUp, forgetPassword, resetPassword } = authClient diff --git a/src/utils/email.ts b/src/utils/email.ts new file mode 100644 index 0000000..ec9825c --- /dev/null +++ b/src/utils/email.ts @@ -0,0 +1,14 @@ +import nodemailer from 'nodemailer' + +export const mailConfig = JSON.parse(import.meta.env.MAILSERVER || '{}') +export const transporter = nodemailer.createTransport(mailConfig) + +export async function sendEmail(to: string, subject: string, html: string) { + const message = { + from: mailConfig.auth.user, + to, + subject, + html + } + await transporter.sendMail(message) +} diff --git a/src/utils/graphQLErrors.ts b/src/utils/graphQLErrors.ts deleted file mode 100644 index ca2090a..0000000 --- a/src/utils/graphQLErrors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GraphQLError } from 'graphql' -import { ApolloServerErrorCode } from '@apollo/server/errors' - -export const AuthenticationError = (message: string = 'Authentication Error') => - new GraphQLError(message, { extensions: { code: 'UNAUTHENTICATED' } }) - -export const ForbiddenError = (message: string = 'Forbidden') => - new GraphQLError(message, { extensions: { code: 'FORBIDDEN' } }) - -export const UserInputError = (message: string = 'Bad input') => - new GraphQLError(message, { - extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT } - }) diff --git a/src/utils/resolvers.ts b/src/utils/resolvers.ts deleted file mode 100644 index b22189a..0000000 --- a/src/utils/resolvers.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ResolverContext } from '@/graphql/apolloClientSSR' -import { AuthenticationError, ForbiddenError } from './graphQLErrors' -import prismaClient from 'prisma/client' -import { COOKIE_NAME, validateSessionToken } from './session' - -export const checkAuth = (next) => async (root, args, context: ResolverContext, info) => { - const { user } = await validateSessionToken(context.cookies?.get(COOKIE_NAME)?.value) - if (!user) throw AuthenticationError() - - return next(root, args, context, info) -} - -export const checkPerm = (perm: string) => (next) => async (root, args, context: ResolverContext, info) => { - const { user } = await validateSessionToken(context.cookies?.get(COOKIE_NAME)?.value) - if (!user) throw AuthenticationError() - - const { roles } = await prismaClient.users.findUnique({ - where: { username: user?.username }, - include: { roles: { include: { role: { select: { permissions: true } } } } } - }) - const perms = roles.map((r) => r.role.permissions).flat() - - if (!perms.includes(perm)) throw ForbiddenError() - - return next(root, args, context, info) -} diff --git a/src/utils/session.ts b/src/utils/session.ts deleted file mode 100644 index 1115bce..0000000 --- a/src/utils/session.ts +++ /dev/null @@ -1,90 +0,0 @@ -// Taken from https://lucia-auth.com -import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding' -import { sha256 } from '@oslojs/crypto/sha2' -import { Argon2id } from 'oslo/password' -import type { AstroCookies } from 'astro' - -import prismaClient from 'prisma/client' -import { type users, type session } from '@prisma/client' - -export const argon2id = new Argon2id() -export const COOKIE_NAME = 'astro_soc' - -export function generateSessionToken(): string { - const bytes = new Uint8Array(20) - crypto.getRandomValues(bytes) - const token = encodeBase32LowerCaseNoPadding(bytes) - return token -} - -export async function createSession(username: string, token: string): Promise { - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))) - const session = await prismaClient.session.create({ - data: { - id: sessionId, - userId: username, - expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) - } - }) - return session -} - -const EMPTY_SESSION = { session: null, user: null } - -export async function validateSessionToken(token?: string): Promise { - if (!token) return EMPTY_SESSION - - const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token))) - const result = await prismaClient.session.findUnique({ - where: { - id: sessionId - }, - include: { - user: { - select: { - username: true, - createdAt: true, - placeholder: true, - imgId: true - } - } - } - }) - - if (result === null) return EMPTY_SESSION - - const { user, ...session } = result - if (Date.now() >= session.expiresAt.getTime()) { - await prismaClient.session.delete({ where: { id: sessionId } }) - return EMPTY_SESSION - } - if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) { - session.expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30) - await prismaClient.session.update({ - where: { id: session.id }, - data: { expiresAt: session.expiresAt } - }) - } - return { session, user } -} - -export async function invalidateSession(sessionId: string): void { - await prismaClient.session.delete({ where: { id: sessionId } }) -} - -export type SessionValidationResult = { session: session; user: users } | { session: null; user: null } - -const COOKIE_OPTIONS = { - httpOnly: true, - sameSite: 'lax', - secure: import.meta.env.PROD, - path: '/' -} - -export function setSessionTokenCookie(cookies: AstroCookies, token: string, expiresAt: Date) { - cookies.set(COOKIE_NAME, token, { ...COOKIE_OPTIONS, expires: expiresAt }) -} - -export function deleteSessionTokenCookie(cookies: AstroCookies) { - cookies.delete(COOKIE_NAME, { ...COOKIE_OPTIONS, maxAge: 0 }) -}