feat: fix authentication

This commit is contained in:
mrjvs
2026-08-08 20:55:56 +02:00
parent 69d133ab85
commit 0e174905f0
16 changed files with 154 additions and 116 deletions

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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<typeof LoginSchema>
import { ServerError } from "nice-grpc";
import { ApiAuthLogin, LoginSchema } from "#shared/api-types"
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
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<ApiAuthLogin> => {
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;

View File

@@ -1,15 +1,16 @@
export interface GetApiAuthMe {
pid: number;
username: string;
}
import type { GetApiAuthMe } from "#shared/api-types"
export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
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,
};
});

View File

@@ -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
}
}
})
});

View File

@@ -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;
}

View File

@@ -1,10 +1,12 @@
import type { ZodType } from "zod";
import type { z, ZodType } from 'zod';
export async function readZodBody<T extends ZodType>(event: H3Event, schema: T) {
export async function readZodBody<T extends ZodType>(event: H3Event, schema: T): Promise<z.infer<T>> {
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;
}

View File

@@ -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<APIDefinition> } | null = null;
let grpc: { channel: Channel } | null = null;
function getGrpcClient<T extends CompatServiceDefinition>(event: H3Event, def: T, token?: string): Client<T> {
const config = useRuntimeConfig(event);
function getGrpcClient(event: typeof H3Event): Client<APIDefinition> {
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<APIDefinition> {
return getGrpcClient(event, APIDefinition);
}
export function useAccountGrpc(event: H3Event): Client<AccountServiceDefinition> {
return getGrpcClient(event, AccountServiceDefinition);
}
export function useApiGrpcWithToken(event: H3Event, token: string): Client<APIDefinition> {
return getGrpcClient(event, APIDefinition, token);
}

21
shared/api-types.ts Normal file
View File

@@ -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<typeof LoginSchema>;

View File

@@ -458,23 +458,23 @@ onMounted(() => {
>
<div class="user-widget-toggle">
<img
src="url"
alt="name"
:src="user.mii?.imageUrl ?? '#'"
:alt="user.mii?.name ?? ''"
>
</div>
<div class="user-widget">
<div class="user-avatar">
<img
src=""
alt="miiname"
:src="user.mii?.imageUrl ?? '#'"
:alt="user.mii?.name ?? ''"
>
</div>
<div class="user-info">
<div class="mii-name">
miiname
<div v-if="user.mii" class="mii-name">
{{ user.mii.name }}
</div>
<div class="pnid">
pnid
{{ user.username }}
</div>
</div>
<div class="buttons">

View File

@@ -5,5 +5,5 @@
<slot />
<Footer />
</div>
</div>
</div>
</template>

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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<string> & { readonly: false }
readonly: false
} satisfies CookieOptions<string> & { readonly: false };
// Not actually a store, but may as well be
export function useAuthStore() {
const authState = useCookie<AuthState | null>("pretendo::auth", {
sameSite: "strict",
const authState = useCookie<AuthState | null>('pretendo::auth', {
sameSite: 'strict',
secure: useRuntimeConfig().public.cookieSecure,
refresh: true,
default: () => null,
default: () => null
});
const accessTokenCookie = useCookie<string | null>("access_token", oldCookieOptions);
const refreshTokenCookie = useCookie<string | null>("refresh_token", oldCookieOptions);
const tokenTypeCookie = useCookie<string | null>("token_type", oldCookieOptions);
const accessTokenCookie = useCookie<string | null>('access_token', oldCookieOptions);
const refreshTokenCookie = useCookie<string | null>('refresh_token', oldCookieOptions);
const tokenTypeCookie = useCookie<string | null>('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
};
}

View File

@@ -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<Me | null>(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
};
});