mirror of
https://github.com/jorgev259/soc_site-astro.git
synced 2025-06-29 07:57:41 +00:00
Implement authentication
This commit is contained in:
parent
cdcd71cf2a
commit
3e4551ea7a
23 changed files with 656 additions and 406 deletions
71
src/components/header/LoginButton.tsx
Normal file
71
src/components/header/LoginButton.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
35
src/components/header/LogoutButton.tsx
Normal file
35
src/components/header/LogoutButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
7
src/env.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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' }] })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
src/middleware.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
90
src/utils/session.ts
Normal 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 })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue