mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-08-24 01:26:57 -05:00
feat: fix authentication
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
21
shared/api-types.ts
Normal 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>;
|
||||
@@ -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">
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
<slot />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user