From 0e174905f0e52fd406b814deafa6f417602ffda1 Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sat, 8 Aug 2026 20:55:56 +0200 Subject: [PATCH] feat: fix authentication --- eslint.config.mjs | 6 +++- nuxt.config.ts | 15 ++++---- server/api/auth/login.post.ts | 29 +++++---------- server/api/auth/me.get.ts | 13 +++---- server/middleware/auth.ts | 31 ++++++++-------- server/utils/enforceAuth.ts | 16 ++++----- server/utils/readZodBody.ts | 14 ++++---- server/utils/useGrpc.ts | 57 +++++++++++++++++++----------- shared/api-types.ts | 21 +++++++++++ src/components/Navbar/Navbar.vue | 14 ++++---- src/layouts/default.vue | 2 +- src/middleware/1.auth.global.ts | 3 +- src/middleware/2.enforce.global.ts | 1 - src/pages/account/login/index.vue | 7 +++- src/stores/auth.ts | 29 ++++++++------- src/stores/me.ts | 12 ++++--- 16 files changed, 154 insertions(+), 116 deletions(-) create mode 100644 shared/api-types.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 8941c1c..1859f36 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,11 +1,15 @@ import pluginVue from 'eslint-plugin-vue'; import eslintConfig from '@pretendonetwork/eslint-config'; import globals from 'globals'; -import { withNuxt } from './.nuxt/eslint.config.mjs'; export default withNuxt([ ...eslintConfig, ...pluginVue.configs['flat/recommended'], + { + rules: { + 'eslint/explicit-function-return-type': 'off' + } + }, { files: ['*.vue', '**/*.vue'], languageOptions: { diff --git a/nuxt.config.ts b/nuxt.config.ts index 537a09c..4c26596 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,7 +2,7 @@ export default defineNuxtConfig({ compatibilityDate: '2026-08-07', srcDir: './src', dir: { - public: './src/public', + public: './src/public' }, nitro: { @@ -12,7 +12,7 @@ export default defineNuxtConfig({ }, modules: [ - "@pinia/nuxt", + '@pinia/nuxt', '@nuxt/eslint', '@nuxt/fonts', '@nuxt/icon', @@ -28,7 +28,7 @@ export default defineNuxtConfig({ runtimeConfig: { nitro: { - envPrefix: "PN_WEBSITE_", + envPrefix: 'PN_WEBSITE_' }, grpcHost: '', grpcApiKey: '', @@ -36,8 +36,7 @@ export default defineNuxtConfig({ public: { apiBase: 'https://api.pretendo.cc', hCaptchaSitekey: '', - homepageUrl: "#", - cookieSecure: false, + cookieSecure: false } }, @@ -52,9 +51,9 @@ export default defineNuxtConfig({ components: [ { - path: "~/components", - pathPrefix: false, - }, + path: '~/components', + pathPrefix: false + } ], content: { diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 1c59e84..865f412 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,20 +1,9 @@ -import z from 'zod'; -import { ServerError } from 'nice-grpc'; - -export type ApiAuthLogin = { - accessToken: string - refreshToken: string; -} - -const LoginSchema = z.object({ - username: z.string(), - password: z.string(), -}) -export type ApiAuthLoginRequest = z.infer +import { ServerError } from "nice-grpc"; +import { ApiAuthLogin, LoginSchema } from "#shared/api-types" export default defineEventHandler(async (event): Promise => { const body = await readZodBody(event, LoginSchema); - const grpc = useGrpc(event); + const grpc = useApiGrpc(event); try { const res = await grpc.login({ @@ -25,21 +14,21 @@ export default defineEventHandler(async (event): Promise => { return { accessToken: res.accessToken, - refreshToken: res.refreshToken, - } + refreshToken: res.refreshToken + }; } catch (error: unknown) { if (error instanceof ServerError) { if (error.details === 'INVALID_ARGUMENT: User not found') { throw createError({ status: 400, - statusText: 'User not found', - }) + statusText: 'User not found' + }); } if (error.details === 'INVALID_ARGUMENT: Password is incorrect') { throw createError({ status: 400, - statusText: 'Password was incorrect', - }) + statusText: 'Password was incorrect' + }); } } throw error; diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index ff32212..c7573de 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -1,15 +1,16 @@ -export interface GetApiAuthMe { - pid: number; - username: string; -} +import type { GetApiAuthMe } from "#shared/api-types" export default defineEventHandler(async (event): Promise => { const auth = enforceLoggedIn(event); - const grpc = useGrpc(event); + const grpc = useAccountGrpc(event); const data = await grpc.getUserData({ pid: auth.pid }); return { pid: data.pid, username: data.username, - } + mii: data.mii ? { + imageUrl: `https://r2-cdn.pretendo.cc/mii/${data.pid}/normal_face.png`, + name: data.mii.name, + } : null, + }; }); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 22d7a9a..c5b3042 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,28 +1,25 @@ -import { Metadata } from "nice-grpc"; -import { useGrpc } from "../utils/useGrpc"; - export default defineEventHandler(async (event) => { - const authHeader = getRequestHeader(event, "authorization"); + const authHeader = getRequestHeader(event, 'authorization'); event.context.auth = null; if (authHeader) { - const grpc = useGrpc(event); try { - const [type, token] = authHeader.split(" ", 2); - if (type !== "Bearer") throw new Error("Invalid token type"); - if (!token) throw new Error("Invalid token"); - const userData = await grpc.getUserData({}, { - metadata: new Metadata({ - 'X-Token': token - }) - }); + const [type, token] = authHeader.split(' ', 2); + if (type !== 'Bearer') { + throw new Error('Invalid token type'); + } + if (!token) { + throw new Error('Invalid token'); + } + const grpc = useApiGrpcWithToken(event, token); + const userData = await grpc.getUserData({}); event.context.auth = { pid: userData.pid, - username: userData.username, - } + username: userData.username + }; } catch (err) { - console.error("Failed to request user data: ", err); + console.error('Failed to request user data: ', err); return; // Continue like nothing happened, further steps will validate if authed } } -}) +}); diff --git a/server/utils/enforceAuth.ts b/server/utils/enforceAuth.ts index 63a1230..b518204 100644 --- a/server/utils/enforceAuth.ts +++ b/server/utils/enforceAuth.ts @@ -1,22 +1,22 @@ export type AuthContext = { pid: number; -} +}; -export function setAuthContext(event: H3Event, context: AuthContext) { +export function setAuthContext(event: H3Event, context: AuthContext): void { event.context.auth = context; } - export function getAuthContext(event: H3Event): AuthContext | null { return event.context.auth ?? null; } - export function enforceLoggedIn(event: H3Event): AuthContext { const context = getAuthContext(event); - if (!context) throw createError({ - status: 401, - statusText: "This action requires authentication", - }) + if (!context) { + throw createError({ + status: 401, + statusText: 'This action requires authentication' + }); + } return context; } diff --git a/server/utils/readZodBody.ts b/server/utils/readZodBody.ts index b943cfb..5872e4c 100644 --- a/server/utils/readZodBody.ts +++ b/server/utils/readZodBody.ts @@ -1,10 +1,12 @@ -import type { ZodType } from "zod"; +import type { z, ZodType } from 'zod'; -export async function readZodBody(event: H3Event, schema: T) { +export async function readZodBody(event: H3Event, schema: T): Promise> { const body = await readValidatedBody(event, schema.safeParse); - if (!body.success) throw createError({ - status: 400, - statusText: 'Invalid input' - }) + if (!body.success) { + throw createError({ + status: 400, + statusText: 'Invalid input' + }); + } return body.data; } diff --git a/server/utils/useGrpc.ts b/server/utils/useGrpc.ts index 44ecfcd..1e30122 100644 --- a/server/utils/useGrpc.ts +++ b/server/utils/useGrpc.ts @@ -1,31 +1,48 @@ -import { APIDefinition } from "@pretendonetwork/grpc/api/api_service"; -import { createChannel, createClient, Channel, type Client, Metadata } from "nice-grpc"; +import { createChannel, createClient, Metadata } from 'nice-grpc'; +import { APIDefinition } from '@pretendonetwork/grpc/api/api_service'; +import { AccountServiceDefinition } from '@pretendonetwork/grpc/account/v2/account_service'; +import type { Channel, Client, CompatServiceDefinition } from 'nice-grpc'; -let grpc: { channel: Channel, client: Client } | null = null; +let grpc: { channel: Channel } | null = null; + +function getGrpcClient(event: H3Event, def: T, token?: string): Client { + const config = useRuntimeConfig(event); -function getGrpcClient(event: typeof H3Event): Client { if (!grpc) { - const config = useRuntimeConfig(); - if (!config.grpcHost || !config.grpcApiKey) { - throw new Error("GRPC not configured"); + if (!config.grpcHost) { + throw new Error('GRPC not configured'); } - const channel = createChannel(config.grpcHost); - const metadata = new Metadata(); - metadata.append("X-API-Key", config.grpcApiKey); grpc = { - channel, - client: createClient(APIDefinition, channel, { - "*": { - metadata - } - }) - } + channel: createChannel(config.grpcHost) + }; } - return grpc.client; + const metadata = new Metadata(); + if (config.grpcApiKey) { + metadata.append('X-API-Key', config.grpcApiKey); + } + if (token) { + metadata.append('X-Token', token); + } + + const client = createClient(def, grpc.channel, { + '*': { + metadata + } + }); + + return client; } -export function useGrpc(event: H3Event) { - return getGrpcClient(event); +export function useApiGrpc(event: H3Event): Client { + return getGrpcClient(event, APIDefinition); +} + +export function useAccountGrpc(event: H3Event): Client { + return getGrpcClient(event, AccountServiceDefinition); +} + +export function useApiGrpcWithToken(event: H3Event, token: string): Client { + return getGrpcClient(event, APIDefinition, token); } diff --git a/shared/api-types.ts b/shared/api-types.ts new file mode 100644 index 0000000..74642fc --- /dev/null +++ b/shared/api-types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export interface GetApiAuthMe { + pid: number; + username: string; + mii: { + imageUrl: string; + name: string; + } | null; +} + +export type ApiAuthLogin = { + accessToken: string; + refreshToken: string; +}; + +export const LoginSchema = z.object({ + username: z.string(), + password: z.string() +}); +export type ApiAuthLoginRequest = z.infer; diff --git a/src/components/Navbar/Navbar.vue b/src/components/Navbar/Navbar.vue index 003fe7b..fc208e5 100644 --- a/src/components/Navbar/Navbar.vue +++ b/src/components/Navbar/Navbar.vue @@ -458,23 +458,23 @@ onMounted(() => { >
name
miiname
+
diff --git a/src/middleware/1.auth.global.ts b/src/middleware/1.auth.global.ts index 79e2a06..27b06b3 100644 --- a/src/middleware/1.auth.global.ts +++ b/src/middleware/1.auth.global.ts @@ -1,4 +1,4 @@ -import type { GetApiAuthMe } from "~/server/api/auth/me.get"; +import type { GetApiAuthMe } from "#shared/api-types" export default defineNuxtRouteMiddleware(async () => { const meStore = useMeStore(); @@ -20,6 +20,7 @@ export default defineNuxtRouteMiddleware(async () => { meStore.setMe({ pid: res.pid, username: res.username, + mii: res.mii, }); } catch { meStore.setMe(null); diff --git a/src/middleware/2.enforce.global.ts b/src/middleware/2.enforce.global.ts index 0c0421d..8ae34a8 100644 --- a/src/middleware/2.enforce.global.ts +++ b/src/middleware/2.enforce.global.ts @@ -6,7 +6,6 @@ export default defineNuxtRouteMiddleware(async (to) => { const meStore = useMeStore(); if (!meStore.loaded) throw new Error("Mestore must be loaded before reaching this middleware"); - // NeedsAuth if (to.meta.needsAuth) { if (!meStore.user) return notAllowed(); } diff --git a/src/pages/account/login/index.vue b/src/pages/account/login/index.vue index 78f7ef1..567c0dc 100644 --- a/src/pages/account/login/index.vue +++ b/src/pages/account/login/index.vue @@ -2,6 +2,7 @@ import { FetchError } from 'ofetch'; const route = useRoute(); +const auth = useAuthStore(); const redirect = computed(() => route.query.redirect); const registerURI = computed(() => `/account/register${redirect.value ? `?redirect=${redirect.value}` : ''}`); @@ -14,9 +15,13 @@ async function loginSubmission() { method: 'POST', body: { username: loginForm.username, - password: loginForm.password, + password: loginForm.password } }); + auth.set({ + accessToken: res.accessToken, + refreshToken: res.refreshToken + }); if (typeof redirect.value === 'string') { await navigateTo(redirect.value); diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 718fcf4..0ddf5d1 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,4 +1,4 @@ -import type { CookieOptions } from "#app"; +import type { CookieOptions } from '#app'; type AuthState = { accessToken: string; @@ -17,28 +17,28 @@ type AuthState = { // because other services use them (and overwrite them) // These are not used by the website codebase for security purposes. -const oldCookieTokenType = "Bearer" -const cookieExpirySec = 7 * 24 * 60 * 60 // 7 days +const oldCookieTokenType = 'Bearer'; +const cookieExpirySec = 7 * 24 * 60 * 60; // 7 days const oldCookieOptions = { domain: '.pretendo.network', secure: false, httpOnly: false, maxAge: cookieExpirySec, refresh: true, - readonly: false, -} satisfies CookieOptions & { readonly: false } + readonly: false +} satisfies CookieOptions & { readonly: false }; // Not actually a store, but may as well be export function useAuthStore() { - const authState = useCookie("pretendo::auth", { - sameSite: "strict", + const authState = useCookie('pretendo::auth', { + sameSite: 'strict', secure: useRuntimeConfig().public.cookieSecure, refresh: true, - default: () => null, + default: () => null }); - const accessTokenCookie = useCookie("access_token", oldCookieOptions); - const refreshTokenCookie = useCookie("refresh_token", oldCookieOptions); - const tokenTypeCookie = useCookie("token_type", oldCookieOptions); + const accessTokenCookie = useCookie('access_token', oldCookieOptions); + const refreshTokenCookie = useCookie('refresh_token', oldCookieOptions); + const tokenTypeCookie = useCookie('token_type', oldCookieOptions); function getToken() { return authState.value?.accessToken ?? null; @@ -53,12 +53,11 @@ export function useAuthStore() { function set(val: AuthState | null) { if (val) { - authState.value = val + authState.value = val; accessTokenCookie.value = val.accessToken; refreshTokenCookie.value = val.refreshToken; tokenTypeCookie.value = oldCookieTokenType; - } - else { + } else { authState.value = null; accessTokenCookie.value = null; refreshTokenCookie.value = null; @@ -69,6 +68,6 @@ export function useAuthStore() { return { getToken, refresh, - set, + set }; } diff --git a/src/stores/me.ts b/src/stores/me.ts index ef16a23..d3ea54f 100644 --- a/src/stores/me.ts +++ b/src/stores/me.ts @@ -1,18 +1,22 @@ export type Me = { pid: number; username: string; + mii: { + imageUrl: string, + name: string, + } | null, }; -export const useMeStore = defineStore("me", () => { +export const useMeStore = defineStore('me', () => { const data = ref(null); const loaded = ref(false); - function setMe(input: Me | null) { + function setMe(input: Me | null): void { data.value = input; loaded.value = true; } - function clear() { + function clear(): void { data.value = null; } @@ -20,6 +24,6 @@ export const useMeStore = defineStore("me", () => { user: computed(() => data.value), loaded: computed(() => loaded.value), setMe, - clear, + clear }; });