another stage of client authentication

This commit is contained in:
DecDuck
2024-10-08 16:13:46 +11:00
parent 909432a6ce
commit 7523e536b5
10 changed files with 345 additions and 82 deletions

View File

@ -1,15 +1,15 @@
<template> <template>
<footer class="bg-gray-950" aria-labelledby="footer-heading"> <footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">Footer</h2> <h2 id="footer-heading" class="sr-only">Footer</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 "> <div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 ">
<div class="xl:grid xl:grid-cols-3 xl:gap-8"> <div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8"> <div class="space-y-8">
<Wordmark class="h-10" /> <Wordmark class="h-10" />
<p class="text-sm leading-6 text-gray-300">An open-source game distribution platform built for <p class="text-sm leading-6 text-zinc-300">An open-source game distribution platform built for
speed, flexibility and beauty.</p> speed, flexibility and beauty.</p>
<div class="flex space-x-6"> <div class="flex space-x-6">
<a v-for="item in navigation.social" :key="item.name" :href="item.href" target="_blank" <a v-for="item in navigation.social" :key="item.name" :href="item.href" target="_blank"
class="text-gray-500 hover:text-gray-400"> class="text-zinc-500 hover:text-zinc-400">
<span class="sr-only">{{ item.name }}</span> <span class="sr-only">{{ item.name }}</span>
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" /> <component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
</a> </a>
@ -21,7 +21,7 @@
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3> <h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.games" :key="item.name"> <li v-for="item in navigation.games" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
</ul> </ul>
@ -30,7 +30,7 @@
<h3 class="text-sm font-semibold leading-6 text-white">Community</h3> <h3 class="text-sm font-semibold leading-6 text-white">Community</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.community" :key="item.name"> <li v-for="item in navigation.community" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
</ul> </ul>
@ -41,7 +41,7 @@
<h3 class="text-sm font-semibold leading-6 text-white">Documentation</h3> <h3 class="text-sm font-semibold leading-6 text-white">Documentation</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.documentation" :key="item.name"> <li v-for="item in navigation.documentation" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
</ul> </ul>
@ -50,7 +50,7 @@
<h3 class="text-sm font-semibold leading-6 text-white">About</h3> <h3 class="text-sm font-semibold leading-6 text-white">About</h3>
<ul role="list" class="mt-6 space-y-4"> <ul role="list" class="mt-6 space-y-4">
<li v-for="item in navigation.about" :key="item.name"> <li v-for="item in navigation.about" :key="item.name">
<a :href="item.href" class="text-sm leading-6 text-gray-300 hover:text-white">{{ <a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
item.name }}</a> item.name }}</a>
</li> </li>
</ul> </ul>

View File

@ -1,66 +1,72 @@
<template> <template>
<div class="bg-gray-950 flex flex-row px-48 py-5"> <div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
<div class="grow inline-flex items-center gap-x-20"> <div class="grow inline-flex items-center gap-x-20">
<Wordmark class="h-8" /> <NuxtLink to="/">
<nav class="inline-flex items-center"> <Wordmark class="h-8" />
<ol class="inline-flex items-center gap-x-12"> </NuxtLink>
<li class="transition text-gray-300 hover:text-gray-100 uppercase font-display font-semibold text-md" <nav class="inline-flex items-center">
v-for="(nav, navIdx) in navigation"> <ol class="inline-flex items-center gap-x-12">
{{ nav.label }} <li
</li> class="transition text-zinc-300 hover:text-zinc-100 uppercase font-display font-semibold text-md"
</ol> v-for="(nav, navIdx) in navigation"
</nav> >
</div> {{ nav.label }}
<div class="inline-flex items-center"> </li>
<ol class="inline-flex gap-3"> </ol>
<li v-for="(item, itemIdx) in quickActions"> </nav>
<HeaderWidget @click="item.action" :notifications="item.notifications">
<component class="h-5" :is="item.icon" />
</HeaderWidget>
</li>
<HeaderUserWidget />
</ol>
</div>
</div> </div>
<div class="inline-flex items-center">
<ol class="inline-flex gap-3">
<li v-for="(item, itemIdx) in quickActions">
<HeaderWidget
@click="item.action"
:notifications="item.notifications"
>
<component class="h-5" :is="item.icon" />
</HeaderWidget>
</li>
<HeaderUserWidget />
</ol>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BellIcon, UserGroupIcon } from '@heroicons/vue/16/solid'; import { BellIcon, UserGroupIcon } from "@heroicons/vue/16/solid";
import type { NavigationItem, QuickActionNav } from '../composables/types'; import type { NavigationItem, QuickActionNav } from "../composables/types";
import HeaderWidget from './HeaderWidget.vue'; import HeaderWidget from "./HeaderWidget.vue";
const navigation: Array<NavigationItem> = [ const navigation: Array<NavigationItem> = [
{ {
prefix: '/store', prefix: "/store",
route: '/store', route: "/store",
label: "Store" label: "Store",
}, },
{ {
prefix: "/library", prefix: "/library",
route: "/library", route: "/library",
label: "Library" label: "Library",
}, },
{ {
prefix: "/community", prefix: "/community",
route: "/community", route: "/community",
label: "Community" label: "Community",
}, },
{ {
prefix: "/news", prefix: "/news",
route: "/news", route: "/news",
label: "News" label: "News",
} },
] ];
const quickActions: Array<QuickActionNav> = [ const quickActions: Array<QuickActionNav> = [
{ {
icon: UserGroupIcon, icon: UserGroupIcon,
action: async () => { } action: async () => {},
}, },
{ {
icon: BellIcon, icon: BellIcon,
action: async () => { } action: async () => {},
} },
] ];
</script>
</script>

View File

@ -1,5 +1,5 @@
<template> <template>
<content class="flex flex-col w-full min-h-screen"> <content class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader /> <UserHeader />
<div class="grow flex"> <div class="grow flex">
<NuxtPage /> <NuxtPage />

View File

@ -1 +1,188 @@
<template></template> <template>
<div
class="min-h-full w-full flex items-center justify-center"
v-if="completed"
>
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Successful!
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-sm">
Drop has successfully authorized the client. You may now close this
window.
</p>
</div>
</div>
</div>
</div>
<main
v-else-if="clientData.data.value"
class="mx-auto grid lg:grid-cols-2 max-w-md lg:max-w-none min-h-full place-items-center w-full gap-4 px-6 py-12 sm:py-32 lg:px-8"
>
<div>
<div class="text-left">
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Authorize client?
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
"{{ clientData.data.value.name }}" has requested access to your Drop
account.
</p>
<div
action="/api/v1/client/callback"
method="post"
class="mt-10 gap-x-6"
>
<input type="text" class="hidden" name="id" :value="clientId" />
<button
@click="() => authorize_wrapper()"
class="rounded-md bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Authorize
</button>
<div v-if="error" class="mt-5 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div class="max-w-2xl">
<p
class="mt-6 font-semibold font-display text-lg leading-8 text-zinc-100"
>
Accepting this request will allow "{{ clientData.data.value.name }}"
on "{{ clientData.data.value.platform }}" to:
</p>
</div>
<div class="mt-8 max-w-2xl sm:mt-12 lg:mt-14">
<dl class="flex flex-col gap-x-8 gap-y-8">
<div
v-for="feature in scopes"
:key="feature.name"
class="flex flex-col"
>
<dt
class="flex items-center gap-x-3 text-base font-semibold font-display leading-7 text-zinc-100"
>
<component
:is="feature.icon"
class="h-5 w-5 flex-none text-blue-600"
aria-hidden="true"
/>
{{ feature.name }}
</dt>
<dd
class="mt-4 flex flex-auto flex-col text-base leading-7 text-zinc-400"
>
<p class="flex-auto">{{ feature.description }}</p>
<p class="mt-1">
<NuxtLink
:href="feature.href"
class="text-sm font-semibold leading-6 text-blue-600"
>Learn more <span aria-hidden="true"></span></NuxtLink
>
</p>
</dd>
</div>
</dl>
</div>
</div>
</main>
<main
v-else-if="clientData.error.value != undefined"
class="grid min-h-full w-full place-items-center px-6 py-24 sm:py-32 lg:px-8"
>
<div class="text-center">
<p class="text-base font-semibold text-blue-600">400</p>
<h1
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
>
Invalid or expired request
</h1>
<p class="mt-6 text-base leading-7 text-zinc-400">
Unfortunately, we couldn't load the authorization request.
</p>
</div>
</main>
</template>
<script setup lang="ts">
import {
ArrowDownTrayIcon,
UserGroupIcon,
XCircleIcon,
} from "@heroicons/vue/16/solid";
import { LockClosedIcon } from "@heroicons/vue/20/solid";
import { CheckCircleIcon } from "@heroicons/vue/24/outline";
const route = useRoute();
const clientId = route.params.id;
const clientData = await useFetch(`/api/v1/client/callback?id=${clientId}`);
const completed = ref(false);
const error = ref();
async function authorize() {
const redirect = await $fetch<string>("/api/v1/client/callback", {
method: "POST",
body: { id: clientId },
});
window.location.replace(redirect);
}
function authorize_wrapper() {
authorize()
.catch((e) => {
const errorMessage = e.statusMessage || "An unknown error occurred.";
error.value = errorMessage;
})
.then(() => {
completed.value = true;
});
}
const scopes = [
{
name: "Access game content and saves",
description:
"The client will be able to download games, sync saves and access game content, like screenshots and mods.",
href: "/docs/access/content",
icon: ArrowDownTrayIcon,
},
{
name: "Access the Drop network",
description:
"The client will be able to establish P2P connections with other users to enable features like download aggregation, Remote LAN play and P2P multiplayer.",
href: "/docs/access/network",
icon: LockClosedIcon,
},
{
name: "Manage your account",
description:
"The client will be able to change your account details, and friend statuses on your behalf.",
href: "/docs/access/account",
icon: UserGroupIcon,
},
];
useHead({
title: "Authorize",
});
</script>

View File

@ -32,7 +32,7 @@
type="username" type="username"
autocomplete="username" autocomplete="username"
required required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
v-model="username" v-model="username"
/> />
</div> </div>
@ -52,7 +52,7 @@
autocomplete="current-password" autocomplete="current-password"
v-model="password" v-model="password"
required required
class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/> />
</div> </div>
</div> </div>
@ -64,7 +64,7 @@
name="remember-me" name="remember-me"
type="checkbox" type="checkbox"
v-model="rememberMe" v-model="rememberMe"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600" class="h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-600"
/> />
<label <label
for="remember-me" for="remember-me"

View File

@ -1,3 +1,27 @@
export default defineEventHandler((h3) => { import clientHandler from "~/server/internal/clients/handler";
}); export default defineEventHandler(async (h3) => {
const userId = await h3.context.session.getUserId(h3);
if (!userId) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const providedClientId = query.id?.toString();
if (!providedClientId)
throw createError({
statusCode: 400,
statusMessage: "Provide client ID in request params as 'id'",
});
const data = await clientHandler.fetchInitiateClientMetadata(
providedClientId
);
if (!data)
throw createError({
statusCode: 404,
statusMessage: "Request not found.",
});
await clientHandler.attachUserId(providedClientId, userId);
return data;
});

View File

@ -1,3 +1,20 @@
export default defineEventHandler((h3) => { import clientHandler from "~/server/internal/clients/handler";
}); export default defineEventHandler(async (h3) => {
const userId = await h3.context.session.getUserId(h3);
if (!userId) throw createError({ statusCode: 403 });
const body = await readBody(h3);
const clientId = await body.id;
const data = await clientHandler.fetchInitiateClientMetadata(clientId);
if (!data)
throw createError({
statusCode: 400,
statusMessage: "Invalid or expired client ID.",
});
const token = await clientHandler.generateAuthToken(clientId);
return `drop://handshake/${clientId}/${token}`;
});

View File

@ -10,7 +10,7 @@ Server responds with a URL to send the user to. It generates a device ID, which
## 2. User signs in ## 2. User signs in
Client sends user to the provided URL (in external browser). User signs in using the existing authentication stack. Client sends user to the provided URL (in external browser). User signs in using the existing authentication stack.
Server sends redirect to drop://handshake/[id]/[token], where the token is an authentication token to generate the necessary certificates, and the ID is the client ID as generated by the server. Server sends redirect to `drop://handshake/[id]/[token]`, where the token is an authentication token to generate the necessary certificates, and the ID is the client ID as generated by the server.
## 3. Client requests certificates ## 3. Client requests certificates
Client makes request: `POST /api/v1/client/handshake` with the token recieved in the previous step. Client makes request: `POST /api/v1/client/handshake` with the token recieved in the previous step.

View File

@ -7,7 +7,12 @@ export interface ClientMetadata {
export class ClientHandler { export class ClientHandler {
private temporaryClientTable: { private temporaryClientTable: {
[key: string]: { timeout: NodeJS.Timeout; data: ClientMetadata }; [key: string]: {
timeout: NodeJS.Timeout;
data: ClientMetadata;
userId?: string;
authToken?: string;
};
} = {}; } = {};
async initiate(metadata: ClientMetadata) { async initiate(metadata: ClientMetadata) {
@ -23,6 +28,28 @@ export class ClientHandler {
return clientId; return clientId;
} }
async fetchInitiateClientMetadata(clientId: string) {
const entry = this.temporaryClientTable[clientId];
if (!entry) return undefined;
return entry.data;
}
async attachUserId(clientId: string, userId: string) {
if (!this.temporaryClientTable[clientId])
throw new Error("Invalid clientId for attaching userId");
this.temporaryClientTable[clientId].userId = userId;
}
async generateAuthToken(clientId: string) {
const entry = this.temporaryClientTable[clientId];
if (!entry) throw new Error("Invalid clientId to generate token");
const token = uuidv4();
this.temporaryClientTable[clientId].authToken = token;
return token;
}
} }
export const clientHandler = new ClientHandler(); export const clientHandler = new ClientHandler();

View File

@ -12,12 +12,14 @@ export default {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["Inter"], sans: ["Inter"],
display: ["Motiva Sans"] display: ["Motiva Sans"],
} },
colors: {
zinc: {
925: "#111112",
},
},
}, },
}, },
plugins: [ plugins: [require("@tailwindcss/forms")],
require('@tailwindcss/forms'), };
],
}