Jason Gorst 1 hónapja
szülő
commit
a94d0c5e9a

+ 112 - 4
app/composables/useAuthClient.js

@@ -1,8 +1,116 @@
+import { createAuthClient } from "better-auth/client"
+import { adminClient, inferAdditionalFields } from "better-auth/client/plugins"
+import { additionalFields } from "#shared/utils/auth/additionalFields.js"
+
+import {
+  ac,
+  admin as adminRole,
+  user as userRole
+} from "#shared/utils/auth/permissions.js"
+
 export default function useAuthClient() {
+  const headers = import.meta.server ? useRequestHeaders() : undefined
+
+  const authClient = createAuthClient({
+    fetchOptions: { headers },
+
+    plugins: [
+      adminClient({ ac, roles: { admin: adminRole, user: userRole } }),
+      inferAdditionalFields(additionalFields)
+    ]
+  })
+
+  const session = useState("auth:session", () => null)
+  const user = useState("auth:user", () => null)
+  const isSignedIn = computed(() => !!session.value)
+
+  const fetchingSession = import.meta.server
+    ? ref(false)
+    : useState("auth:fetchingSession", () => false)
+
+  async function signIn({ email, password, rememberMe, redirectTo } = {}) {
+    // noinspection JSUnresolvedReference
+    const result = await authClient.signIn.email({
+      email,
+      password,
+      rememberMe
+    })
+
+    if (redirectTo) {
+      await navigateTo(redirectTo)
+    } else {
+      const route = useRoute()
+
+      if (route.name === "characterView") {
+        await navigateTo({ name: "characterEdit", params: route.params })
+      }
+    }
+
+    return result
+  }
+
+  async function signOut({ redirectTo } = {}) {
+    // noinspection JSUnresolvedReference
+    const result = await authClient.signOut()
+    session.value = null
+    user.value = null
+
+    if (redirectTo) {
+      await navigateTo(redirectTo)
+    } else {
+      const route = useRoute()
+
+      if (route.name === "characterEdit") {
+        await navigateTo({ name: "characterView", params: route.params })
+      } else {
+        // noinspection JSUnresolvedReference
+        if (
+          _includes(route.meta?.middleware, "signed-in") ||
+          _includes(route.meta?.middleware, "admin")
+        ) {
+          await navigateTo({ name: "characters" })
+        }
+      }
+    }
+
+    return result
+  }
+
+  async function fetchSession() {
+    if (fetchingSession.value) {
+      console.log("[useAuthClient] [fetchSession] already fetching session")
+      return
+    }
+
+    fetchingSession.value = true
+    // noinspection JSUnresolvedReference
+    const { data } = await authClient.getSession({ fetchOptions: { headers } })
+    session.value = data?.session || null
+    user.value = data?.user || null
+    fetchingSession.value = false
+
+    return data
+  }
+
+  if (import.meta.client) {
+    authClient.$store.listen("$sessionSignal", async (signal) => {
+      if (!signal) {
+        return
+      }
+
+      await fetchSession()
+    })
+  }
+
+  // noinspection JSUnresolvedReference
   return {
-    isSignedIn: ref(false),
-    signIn: _noop(),
-    signOut: _noop(),
-    user: ref(null)
+    session,
+    user,
+    isSignedIn,
+    signIn,
+    signUp: authClient.signUp,
+    signOut,
+    fetchSession,
+    authClient
   }
 }

+ 11 - 0
app/plugins/auth.client.js

@@ -0,0 +1,11 @@
+// noinspection JSUnusedGlobalSymbols
+export default defineNuxtPlugin(async (nuxtApp) => {
+  if (!nuxtApp.payload.serverRendered) {
+    await useAuthClient().fetchSession()
+  } else if (nuxtApp.payload.prerenderedAt || nuxtApp.payload.isCached) {
+    // To avoid hydration mismatch
+    nuxtApp.hook("app:mounted", async () => {
+      await useAuthClient().fetchSession()
+    })
+  }
+})

+ 19 - 0
app/plugins/auth.server.js

@@ -0,0 +1,19 @@
+// noinspection JSUnresolvedReference,JSUnusedGlobalSymbols
+
+export default defineNuxtPlugin({
+  name: "better-auth-fetch-plugin",
+  enforce: "pre",
+
+  async setup(nuxtApp) {
+    // Flag if request is cached
+    nuxtApp.payload.isCached = useRequestEvent()?.context.cache
+
+    if (
+      nuxtApp.payload.serverRendered &&
+      !nuxtApp.payload.prerenderedAt &&
+      !nuxtApp.payload.isCached
+    ) {
+      await useAuthClient().fetchSession()
+    }
+  }
+})

+ 65 - 0
prisma/auth.prisma

@@ -0,0 +1,65 @@
+model User {
+  id            String    @id @default(dbgenerated("gen_random_uuid()"))
+  name          String
+  email         String
+  emailVerified Boolean   @default(false)
+  image         String?
+  createdAt     DateTime  @default(now())
+  updatedAt     DateTime  @default(now()) @updatedAt
+  role          String?
+  banned        Boolean?  @default(false)
+  banReason     String?
+  banExpires    DateTime?
+  username      String
+  sessions      Session[]
+  accounts      Account[]
+
+  @@unique([email])
+  @@map("user")
+}
+
+model Session {
+  id             String   @id @default(dbgenerated("gen_random_uuid()"))
+  expiresAt      DateTime
+  token          String
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+  ipAddress      String?
+  userAgent      String?
+  userId         String
+  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  impersonatedBy String?
+
+  @@unique([token])
+  @@map("session")
+}
+
+model Account {
+  id                    String    @id @default(dbgenerated("gen_random_uuid()"))
+  accountId             String
+  providerId            String
+  userId                String
+  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
+  accessToken           String?
+  refreshToken          String?
+  idToken               String?
+  accessTokenExpiresAt  DateTime?
+  refreshTokenExpiresAt DateTime?
+  scope                 String?
+  password              String?
+  createdAt             DateTime  @default(now())
+  updatedAt             DateTime  @updatedAt
+
+  @@map("account")
+}
+
+model Verification {
+  id         String   @id @default(dbgenerated("gen_random_uuid()"))
+  identifier String
+  value      String
+  expiresAt  DateTime
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @default(now()) @updatedAt
+
+  @@map("verification")
+}

+ 3 - 0
server/api/auth/[...all].js

@@ -0,0 +1,3 @@
+export default defineEventHandler((event) => {
+  return auth.handler(toWebRequest(event))
+})

+ 0 - 2
server/socketio/middlewares/authMiddleware.js

@@ -1,6 +1,4 @@
-// noinspection JSUnusedGlobalSymbols
 export async function authMiddleware(socket, next) {
-  // noinspection JSUnresolvedReference
   const session = await auth.api.getSession({
     headers: socket.handshake.headers
   })

+ 8 - 0
server/utils/AuthError.js

@@ -0,0 +1,8 @@
+export default class AuthError extends Error {
+  constructor(message, options) {
+    super(message, options)
+    this.name = "AuthError"
+    this.resource = options?.resource
+    this.permissions = options?.permissions
+  }
+}

+ 39 - 0
server/utils/auth.js

@@ -0,0 +1,39 @@
+// noinspection ES6PreferShortImport
+
+import "dotenv/config"
+import prisma from "./prisma.js"
+import { betterAuth } from "better-auth"
+import { admin as adminPlugin } from "better-auth/plugins"
+import { prismaAdapter } from "better-auth/adapters/prisma"
+import { additionalFields } from "../../shared/utils/auth/additionalFields.js"
+import { ac, admin, user } from "../../shared/utils/auth/permissions.js"
+
+const auth = betterAuth({
+  baseURL: process.env.NUXT_BETTER_AUTH_BASE_URL,
+  secret: process.env.NUXT_BETTER_AUTH_SECRET,
+  database: prismaAdapter(prisma, { provider: "postgresql" }),
+  advanced: { database: { generateId: false } },
+  account: { accountLinking: { enabled: true } },
+  session: { cookieCache: { enabled: true, maxAge: 5 * 60 } },
+  user: { additionalFields: additionalFields.user },
+
+  emailAndPassword: {
+    enabled: true,
+    minPasswordLength: 12,
+    maxPasswordLength: 128
+  },
+
+  plugins: [adminPlugin({ ac, roles: { admin, user } })]
+})
+
+let _auth
+
+function serverAuth() {
+  if (!_auth) {
+    _auth = auth
+  }
+
+  return _auth
+}
+
+export { auth, serverAuth }

+ 10 - 0
shared/utils/auth/additionalFields.js

@@ -0,0 +1,10 @@
+export const additionalFields = {
+  user: {
+    username: {
+      type: "string",
+      required: true,
+      input: true,
+      returned: true
+    }
+  }
+}

+ 16 - 0
shared/utils/auth/permissions.js

@@ -0,0 +1,16 @@
+import { createAccessControl } from "better-auth/plugins/access"
+import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
+
+export const ac = createAccessControl({
+  ...defaultStatements,
+  character: ["create", "update", "delete"]
+})
+
+export const user = ac.newRole({
+  character: ["create", "update", "delete"]
+})
+
+export const admin = ac.newRole({
+  character: ["create", "update", "delete"],
+  ...adminAc.statements
+})