Implement authentication

This commit is contained in:
Jorge Vargas 2024-11-20 16:28:22 -06:00
parent cdcd71cf2a
commit 3e4551ea7a
23 changed files with 656 additions and 406 deletions

View file

@ -0,0 +1,71 @@
import { useState } from 'react'
import { gql } from '@/graphql/__generated__/client'
import { useMutation } from '@apollo/client/react/hooks'
import Button from 'components/Button'
import * as m from 'paraglide/messages.js'
import Modal from 'components/Modal'
import apolloClient from '@/graphql/apolloClient'
import toast from 'react-hot-toast'
const loginMutation = gql(`
mutation Login($username: String!, $password: String!) {
login(username: $username, password: $password)
}
`)
export default function LoginBtn() {
const [modalOpen, setModalOpen] = useState(false)
const [mutate, { loading }] = useMutation(loginMutation, { client: apolloClient })
const handleSubmit = (ev) => {
ev.preventDefault()
const formData = new FormData(ev.target)
const variables = Object.fromEntries(formData)
mutate({ variables })
.then((res) => {
// toast.success(m.emailSuccess())
setModalOpen(false)
})
.catch((err) => {
toast.error(err.message)
})
}
return (
<>
<Button className='rounded-t-none' onClick={() => setModalOpen(true)}>
{m.login()}
</Button>
{modalOpen ? (
<Modal setOpen={setModalOpen}>
<form onSubmit={handleSubmit}>
<div className='bg-white px-4 pt-5 pb-4 gap-x-4 flex flex-col'>
<div className='flex gap-x-4'>
<div className='flex flex-col'>
<label htmlFor='username' className='font-medium text-black'>
{m.username()}:
</label>
<input type='text' name='username' className='bg-zinc-200 rounded p-2 mt-2 mb-3 text-black' />
</div>
</div>
<div className='flex flex-col'>
<label htmlFor='password' className='font-medium text-black'>
{m.password()}:
</label>
<input type='password' name='password' className='bg-zinc-200 rounded p-2 mt-2 mb-3 text-black' />
</div>
</div>
<div className='bg-zinc-200 px-4 py-3 text-right gap-x-2 flex justify-end'>
<Button className='bg-zinc-500 hover:bg-zinc-600'>{m.close()}</Button>
<Button loading={loading} disabled={loading}>
{m.login()}
</Button>
</div>
</form>
</Modal>
) : null}
</>
)
}

View file

@ -1,15 +1,12 @@
---
import { getSession } from 'auth-astro/server'
import { SignIn, SignOut } from 'auth-astro/components'
import RegisterBtn from './RegisterButton'
import * as m from 'paraglide/messages.js'
import LoginBtn from './LoginButton'
import LogoutBtn from './LogoutButton'
const session = await getSession(Astro.request)
const btnClass = 'bg-blue-600 hover:bg-blue-700 py-2 px-3.5 rounded-lg rounded-t-none'
const { user } = Astro.locals
---
<div class='absolute top-0 right-0 space-x-2 mr-10'>
{session === null ? <SignIn class={btnClass}>{m.login()}</SignIn> : <SignOut class={btnClass}>{m.logout()}</SignOut>}
{!session ? <RegisterBtn client:only='react' /> : null}
{!user ? <LoginBtn client:only='react' /> : <LogoutBtn client:only='react' />}
{!user ? <RegisterBtn client:only='react' /> : null}
</div>

View file

@ -0,0 +1,35 @@
import { gql } from '@/graphql/__generated__/client'
import { useMutation } from '@apollo/client/react/hooks'
import Button from 'components/Button'
import * as m from 'paraglide/messages.js'
import apolloClient from '@/graphql/apolloClient'
import toast from 'react-hot-toast'
const loginMutation = gql(`
mutation Logout {
logout
}
`)
export default function LogoutBtn() {
const [mutate, { loading }] = useMutation(loginMutation, { client: apolloClient })
const handleSubmit = (ev) => {
ev.preventDefault()
mutate()
.then(() => {
window.refresh()
})
.catch((err) => {
toast.error(err.message)
})
}
return (
<Button className='rounded-t-none' loading={loading} onClick={handleSubmit}>
{m.logout()}
</Button>
)
}

View file

@ -1,4 +1,3 @@
import type { Session } from '@auth/core/types'
import { useState } from 'react'
import { gql } from '@/graphql/__generated__/client'
import { useMutation } from '@apollo/client/react/hooks'
@ -15,13 +14,10 @@ const registerMutation = gql(`
}
`)
export default function RegisterBtn(props: { session: Session }) {
const { session } = props
export default function RegisterBtn() {
const [modalOpen, setModalOpen] = useState(false)
const [mutate, { loading }] = useMutation(registerMutation, { client: apolloClient, ignoreResults: true })
if (session) return null
const handleSubmit = (ev) => {
ev.preventDefault()
const formData = new FormData(ev.target)

7
src/env.d.ts vendored
View file

@ -1 +1,8 @@
/// <reference path="../.astro/types.d.ts" />
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
}
}

View file

@ -9,7 +9,8 @@ const httpLink = createUploadLink({
const apolloClient = new ApolloClient({
link: httpLink,
cache: new InMemoryCache()
cache: new InMemoryCache(),
credentials: 'same-origin'
})
export default apolloClient

View file

@ -1,20 +1,18 @@
import { ApolloClient, InMemoryCache } from '@apollo/client/core'
import { SchemaLink } from '@apollo/client/link/schema'
import { getSession } from 'auth-astro/server'
import type { Session } from '@auth/core/types'
import prismaClient, { type users } from 'prisma/client'
import type { AstroCookies, AstroGlobal } from 'astro'
import schema from './schema'
export type ResolverContext = { request?: Request; session?: Session; user?: users }
export async function getApolloClient(request?: Request) {
const session = async () => (request ? getSession(request) : null)
const user = async () => (session ? prismaClient.users.findUnique({ where: { username: session.id } }) : null)
export interface ResolverContext {
cookies?: AstroCookies
}
export async function getApolloClient(cookies?: AstroCookies) {
return new ApolloClient({
ssrMode: true,
link: new SchemaLink({ schema, context: { request, session, user } }),
cache: new InMemoryCache()
link: new SchemaLink({ schema, context: { cookies } }),
cache: new InMemoryCache(),
credentials: 'same-origin'
})
}

View file

@ -1,15 +1,23 @@
import { composeResolvers } from '@graphql-tools/resolvers-composition'
import type { Resolvers } from '@/graphql/__generated__/types.generated'
import generator from 'generate-password-ts'
import bcrypt from 'bcrypt'
import sharp from 'sharp'
import fs from 'fs-extra'
import type { User } from '@auth/core/types'
import prismaClient from 'prisma/client'
import nodemailer from 'nodemailer'
import { UserInputError } from 'utils/graphQLErrors'
import { AuthenticationError, UserInputError } from 'utils/graphQLErrors'
import forgorTemplate from 'utils/forgorTemplate'
import {
argon2id,
COOKIE_NAME,
createSession,
generateSessionToken,
invalidateSession,
setSessionTokenCookie,
validateSessionToken
} from 'utils/session'
async function processImage(imagePath) {
const sharpImg = sharp(imagePath)
@ -62,6 +70,12 @@ async function cropPFP(pfpFile: File, username: string, imgId: string) {
export const mailConfig = JSON.parse(process.env.MAILSERVER || '{}')
export const transporter = nodemailer.createTransport(mailConfig)
function addHours(date: Date, hours: number) {
const hoursToAdd = hours * 60 * 60 * 1000
date.setTime(date.getTime() + hoursToAdd)
return date
}
async function createForgor(user: User) {
await prismaClient.forgors.deleteMany({ where: { username: user.username } })
@ -72,7 +86,9 @@ async function createForgor(user: User) {
strict: true
})
const row = await prismaClient.forgors.create({ data: { key, username: user.username } })
const row = await prismaClient.forgors.create({
data: { key, username: user.username, expires: addHours(new Date(), 24) }
})
const html = forgorTemplate.replaceAll('{{forgor_link}}', `https://sittingonclouds.net/forgor?key=${key}`)
const message = {
from: mailConfig.auth.user,
@ -104,12 +120,34 @@ const resolvers: Resolvers = {
upercase: true,
strict: true
})
const hashPassword = await argon2id.hash(password)
const imgId = pfp.size > 0 ? Date.now().toString() : null
const [hash, placeholder] = await Promise.all([bcrypt.hash(password, 10), cropPFP(pfp, username, imgId)])
const [hash, placeholder] = await Promise.all([hashPassword, cropPFP(pfp, username, imgId)])
const user = await prismaClient.users.create({ data: { username, email, password: hash, placeholder, imgId } })
await createForgor(user)
return true
},
login: async (_, args, context) => {
const { username, password } = args
let { user } = await validateSessionToken(context.cookies?.get(COOKIE_NAME)?.value)
if (user) throw AuthenticationError('Already logged in')
user = await prismaClient.users.findUnique({ where: { username } })
if (!user) throw UserInputError('Invalid Username/email')
const checkPassword = await argon2id.verify(user.password, password)
if (!checkPassword) throw UserInputError('Invalid Username/email')
const token = generateSessionToken()
const session = await createSession(user.username, token)
setSessionTokenCookie(context.cookies, token, session.expiresAt)
return token
},
logout: async (_, args, context) => {
await invalidateSession(context.locals.session.id)
return true
}
/*

View file

@ -1,40 +1,31 @@
// import fg from 'fast-glob'
import { composeResolvers } from '@graphql-tools/resolvers-composition'
import fg from 'fast-glob'
import { composeResolvers } from '@graphql-tools/resolvers-composition'
import type { Resolvers } from '@/graphql/__generated__/types.generated'
import prismaClient from 'prisma/client'
import { checkPerm } from 'utils/resolvers'
const resolversComposition = {
/* 'Query.banners': hasRole('UPDATE') */
'Query.banners': checkPerm('UPDATE')
}
const resolvers: Resolvers = {
Query: {
config: async (_, { name }) =>
config: (_, { name }, __, ___) =>
prismaClient.config.upsert({
where: { name },
create: { name },
update: {}
})
/* highlight: async (parent, args, { db }) => {
const { value } = await db.models.config.findByPk('highlight')
return db.models.album.findByPk(value)
},
banners: async (parent, args) => {
}),
banners: async (parent, args) => {
const filePaths = await fg(['/var/www/soc_img/img/live/**/ /**.png'])
const images = filePaths.map((f) => f.split('/').pop())
const images = filePaths.map((f) => f.split('/').pop())
return images
},
recentComments: async (parent, { limit = 5 }, { db }) => {
return db.models.comment.findAll({
limit,
order: [['updatedAt', 'DESC']]
})
} */
return images
},
highlight: () => prismaClient.config.findUnique({ where: { name: 'highlight' } }),
recentComments: async (parent, { limit: take = 5 }) =>
prismaClient.comments.findMany({ take, orderBy: [{ updatedAt: 'desc' }] })
}
}

View file

@ -38,6 +38,9 @@ type Query {
}
type Mutation {
login(username: String!, password: String!): String!
logout: Boolean!
registerUser(email: String!, username: String!, pfp: File): Boolean!
updateUserRoles(username: String!, roles: [String]!): Boolean!
deleteUser(username: String!): Int

22
src/middleware.ts Normal file
View file

@ -0,0 +1,22 @@
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) {
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()
})

View file

@ -1,13 +1,13 @@
import { GraphQLError } from 'graphql'
import { ApolloServerErrorCode } from '@apollo/server/errors'
export const AuthenticationError = (message: string = '') =>
export const AuthenticationError = (message: string = 'Authentication Error') =>
new GraphQLError(message, { extensions: { code: 'UNAUTHENTICATED' } })
export const ForbiddenError = (message: string = '') =>
export const ForbiddenError = (message: string = 'Forbidden') =>
new GraphQLError(message, { extensions: { code: 'FORBIDDEN' } })
export const UserInputError = (message: string = '') =>
export const UserInputError = (message: string = 'Bad input') =>
new GraphQLError(message, {
extensions: { code: ApolloServerErrorCode.BAD_USER_INPUT }
})

View file

@ -1,32 +1,26 @@
// import User from "sequelize/models/user";
import type { Session } from '@auth/core'
import type { ResolverContext } from '@/graphql/apolloClientSSR'
import { AuthenticationError, ForbiddenError } from './graphQLErrors'
import prismaClient from 'prisma/client'
import { COOKIE_NAME, validateSessionToken } from './session'
export async function getUser(session: Session | null) {
if (!session) return null
const { id } = session
const user = await User.findByPk(id)
return user
}
const isAuthed = (next) => async (root, args, context, info) => {
const session = await getSession()
const { username = null } = session
if (!username) throw AuthenticationError()
return next(root, args, context, info)
}
const hasPerm = (perm) => (next) => async (root, args, context, info) => {
const { db } = context
const user = await getUser(db)
const roles = await user.getRoles()
const permissions = roles.map((r) => r.permissions).flat()
if (!permissions.includes(perm)) throw ForbiddenError()
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)
}
consoley.log()
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()
export const hasRole = (role) => [isAuthedApp, hasPermApp(role)]
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)
}

90
src/utils/session.ts Normal file
View file

@ -0,0 +1,90 @@
// 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<session> {
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<SessionValidationResult> {
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 })
}