diff --git a/apps/website/.example.dev.vars b/apps/website/.example.dev.vars new file mode 100644 index 0000000..754628a --- /dev/null +++ b/apps/website/.example.dev.vars @@ -0,0 +1,6 @@ +PRO= + +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_REDIRECT_URI=/auth/callback/discord diff --git a/apps/website/.gitignore b/apps/website/.gitignore index 6d4c0aa..57729bd 100644 --- a/apps/website/.gitignore +++ b/apps/website/.gitignore @@ -19,3 +19,6 @@ pnpm-debug.log* # macOS-specific files .DS_Store + +.dev.vars +.wrangler/ diff --git a/apps/website/astro.config.ts b/apps/website/astro.config.ts index 94fb94e..2c9091c 100644 --- a/apps/website/astro.config.ts +++ b/apps/website/astro.config.ts @@ -5,7 +5,6 @@ import preact from "@astrojs/preact"; import sitemap from "@astrojs/sitemap"; import tailwind from "@astrojs/tailwind"; import cloudflare from "@astrojs/cloudflare"; -import auth from "auth-astro"; import { CONFIG } from "./src/config"; @@ -14,17 +13,17 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // https://astro.build/config export default defineConfig({ site: CONFIG.origin, - integrations: [sitemap(), tailwind(), auth(), preact()], + integrations: [sitemap(), tailwind(), preact()], output: "server", adapter: cloudflare(), + security: { + checkOrigin: true, + }, vite: { resolve: { alias: { "~": path.resolve(__dirname, "./src"), }, }, - ssr: { - external: ["node:path", "path", "os", "crypto"], - }, }, }); diff --git a/apps/website/bun.lockb b/apps/website/bun.lockb index 04bf404..9cfc3b0 100755 Binary files a/apps/website/bun.lockb and b/apps/website/bun.lockb differ diff --git a/apps/website/migrations/0001_auth.sql b/apps/website/migrations/0001_auth.sql new file mode 100644 index 0000000..799cb93 --- /dev/null +++ b/apps/website/migrations/0001_auth.sql @@ -0,0 +1,18 @@ +-- Migration number: 0001 2024-08-03T15:43:57.349Z + +CREATE TABLE IF NOT EXISTS user ( + id TEXT NOT NULL PRIMARY KEY, + discord_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + avatar TEXT NOT NULL, + access_token TEXT NOT NULL, + access_token_expiration NUMBER NOT NULL, + refresh_token TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS session ( + id TEXT NOT NULL PRIMARY KEY, + expires_at INTEGER NOT NULL, + user_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES user (id) +); diff --git a/apps/website/package.json b/apps/website/package.json index c6424d3..5de2235 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,13 +14,13 @@ "@astrojs/prefetch": "^0.2.1", "@astrojs/sitemap": "^3.1.6", "@astrojs/tailwind": "^5.1.0", - "@auth/core": "^0.32.0", + "@lucia-auth/adapter-sqlite": "^3.0.2", "@tailwindcss/typography": "^0.5.9", + "arctic": "^1.9.2", "astro": "^4.11.5", "astro-google-fonts-optimizer": "^0.2.2", "astro-icon": "^0.8.0", - "auth-astro": "^4.1.2", - "dotenv": "^16.4.5", + "lucia": "^3.2.0", "preact": "^10.23.1", "tailwindcss": "^3.3.1" }, diff --git a/apps/website/src/components/dashboard/UserInfo.astro b/apps/website/src/components/dashboard/UserInfo.astro index 7261fc1..88cf86b 100644 --- a/apps/website/src/components/dashboard/UserInfo.astro +++ b/apps/website/src/components/dashboard/UserInfo.astro @@ -1,28 +1,12 @@ --- import { Image } from "astro:assets"; -import type { User } from "~/env"; -import { getUser } from "~/lib/user"; -interface Props { - user?: User; -} - -let user: User; -if (!Astro.props.user) { - const u = await getUser(Astro.request); - if (!u) { - return Astro.redirect("/auth/login"); - } - - user = u; -} else { - user = Astro.props.user; -} +const user = Astro.locals.user; ---
icon

- @{user.name} + @{user.username}

diff --git a/apps/website/src/env.d.ts b/apps/website/src/env.d.ts index 1bcd6b7..6577947 100644 --- a/apps/website/src/env.d.ts +++ b/apps/website/src/env.d.ts @@ -1,10 +1,13 @@ /// -import type { User as AuthCoreUser } from "@auth/core/types"; - -export type User = AuthCoreUser & { - global_name: string; - discordAccessToken: string; +type D1Database = import("@cloudflare/workers-types").D1Database; +type ENV = { + GITHUB_APP_NAME: string; + DISCORD_CLIENT_ID: string; + DISCORD_CLIENT_SECRET: string; + DISCORD_BOT_TOKEN: string; + DISCORD_REDIRECT_URI: string; + DB: D1Database; }; export interface Guild { @@ -19,3 +22,15 @@ export interface MutualeGuild { mutual: boolean; guild: Guild; } + +type Runtime = import("@astrojs/cloudflare").Runtime; + +// https://github.com/withastro/astro/issues/7394#issuecomment-1975657601 +declare namespace App { + interface Locals extends Runtime { + discord: import("arctic").Discord; + lucia: import("lucia").Lucia; + session: import("lucia").Session | null; + user: import("lucia").User | null; + } +} diff --git a/apps/website/src/lib/auth.ts b/apps/website/src/lib/auth.ts new file mode 100644 index 0000000..c7fa454 --- /dev/null +++ b/apps/website/src/lib/auth.ts @@ -0,0 +1,54 @@ +import { Lucia } from "lucia"; +import { D1Adapter } from "@lucia-auth/adapter-sqlite"; +import { Discord } from "arctic"; + +export function initializeLucia(D1: D1Database) { + const adapter = new D1Adapter(D1, { + user: "user", + session: "session", + }); + return new Lucia(adapter, { + sessionCookie: { + attributes: { + // set to `true` when using HTTPS + secure: import.meta.env.PROD, + }, + }, + getUserAttributes: (attributes) => { + return { + discordId: attributes.discord_id, + username: attributes.username, + avatar: attributes.avatar, + sensitive: { + accessToken: attributes.access_token, + refreshToken: attributes.refresh_token, + accessTokenExpiresAt: attributes.access_token_expiration, + }, + }; + }, + }); +} + +export function initializeDiscord( + discordClientId: string, + discordClientSecret: string, + redirectUri: string +) { + return new Discord(discordClientId, discordClientSecret, redirectUri); +} + +declare module "lucia" { + interface Register { + Lucia: ReturnType; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + discord_id: number; + username: string; + avatar: string; + access_token: string; + refresh_token: string; + access_token_expiration: number; +} diff --git a/apps/website/src/lib/guilds.ts b/apps/website/src/lib/guilds.ts index b7996f9..502c7da 100644 --- a/apps/website/src/lib/guilds.ts +++ b/apps/website/src/lib/guilds.ts @@ -1,24 +1,25 @@ -import type { Guild, MutualeGuild, User } from "~/env"; +import type { Guild, MutualeGuild } from "~/env"; import { isUserEligible } from "./user"; export async function getGuild( - user: User, - guildId: string + user, + guildId: string, + env ): Promise { const guilds = await getUserGuilds(user); const guild = guilds.find((g) => g.id === guildId); - if (!guild || !(await isMutualGuild(guild))) return undefined; + if (!guild || !(await isMutualGuild(guild, env))) return undefined; return guild; } -export async function getUserGuilds(user: User): Promise { +export async function getUserGuilds(user): Promise { const discordApiGuildsResponse = await fetch( "https://discord.com/api/v10/users/@me/guilds", { headers: { - Authorization: `Bearer ${user.discordAccessToken as string}`, + Authorization: `Bearer ${user.sensitive.accessToken as string}`, "Cache-Control": "max-age=300", }, } @@ -28,7 +29,8 @@ export async function getUserGuilds(user: User): Promise { } export async function filterUserGuilds( - guilds: Guild[] + guilds: Guild[], + env ): Promise { const filtered = guilds .filter(isUserEligible) @@ -37,7 +39,7 @@ export async function filterUserGuilds( const result: MutualeGuild[] = []; for (const guild of filtered) { - const mutual = await isMutualGuild(guild); + const mutual = await isMutualGuild(guild, env); result.push({ mutual, guild }); } @@ -47,14 +49,12 @@ export async function filterUserGuilds( return result; } -export async function isMutualGuild(guild: Guild): Promise { +export async function isMutualGuild(guild: Guild, env): Promise { const res = await fetch( - `https://discord.com/api/v10/guilds/${guild.id}/members/${ - import.meta.env.DISCORD_CLIENT_ID - }`, + `https://discord.com/api/v10/guilds/${guild.id}/members/${env.DISCORD_CLIENT_ID}`, { headers: { - Authorization: `Bot ${import.meta.env.DISCORD_BOT_TOKEN}`, + Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, }, } ); diff --git a/apps/website/src/lib/user.ts b/apps/website/src/lib/user.ts index de9ea56..a98dab3 100644 --- a/apps/website/src/lib/user.ts +++ b/apps/website/src/lib/user.ts @@ -1,12 +1,4 @@ -import { getSession } from "auth-astro/server"; -import type { Guild, User } from "~/env"; - -export async function getUser(req: Request): Promise { - const session = await getSession(req); - if (!session || !session.user) return null; - - return session.user as User; -} +import type { Guild } from "~/env"; // Checks if user has AMDINISTRATOR permissions in the guild export function isUserEligible(guild: Guild): boolean { diff --git a/apps/website/src/middleware.ts b/apps/website/src/middleware.ts new file mode 100644 index 0000000..7c50700 --- /dev/null +++ b/apps/website/src/middleware.ts @@ -0,0 +1,40 @@ +import { initializeLucia, initializeDiscord } from "./lib/auth"; +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware(async (context, next) => { + const lucia = initializeLucia(context.locals.runtime.env.DB); + context.locals.lucia = lucia; + const discord = initializeDiscord( + context.locals.runtime.env.DISCORD_CLIENT_ID, + context.locals.runtime.env.DISCORD_CLIENT_SECRET, + context.locals.runtime.env.DISCORD_REDIRECT_URI + ); + context.locals.discord = discord; + const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null; + if (!sessionId) { + context.locals.user = null; + context.locals.session = null; + return next(); + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + context.cookies.set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + context.cookies.set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + } + context.locals.session = session; + context.locals.user = user; + return next(); +}); diff --git a/apps/website/src/pages/api/user/guilds.ts b/apps/website/src/pages/api/user/guilds.ts index 13a3a47..8328a71 100644 --- a/apps/website/src/pages/api/user/guilds.ts +++ b/apps/website/src/pages/api/user/guilds.ts @@ -1,9 +1,8 @@ import type { APIRoute } from "astro"; import { filterUserGuilds, getUserGuilds } from "~/lib/guilds"; -import { getUser } from "~/lib/user"; -export const GET: APIRoute = async ({ request }) => { - const user = await getUser(request); +export const GET: APIRoute = async ({ locals }) => { + const user = locals.user; if (!user) { return new Response(null, { status: 401, @@ -11,7 +10,7 @@ export const GET: APIRoute = async ({ request }) => { } const guilds = await getUserGuilds(user); - const res = await filterUserGuilds(guilds); + const res = await filterUserGuilds(guilds, locals.runtime.env); return Response.json(res); }; diff --git a/apps/website/src/pages/auth/callback/discord.ts b/apps/website/src/pages/auth/callback/discord.ts new file mode 100644 index 0000000..e180945 --- /dev/null +++ b/apps/website/src/pages/auth/callback/discord.ts @@ -0,0 +1,100 @@ +import { OAuth2RequestError } from "arctic"; +import type { APIContext } from "astro"; +import { generateIdFromEntropySize } from "lucia"; + +type UserRow = { + id: string; +}; + +export async function GET(context: APIContext): Promise { + const code = context.url.searchParams.get("code"); + const state = context.url.searchParams.get("state"); + const storedState = context.cookies.get("discord_oauth_state")?.value ?? null; + if (!code || !state || !storedState || state !== storedState) { + return new Response(null, { + status: 400, + }); + } + + try { + const tokens = await context.locals.discord.validateAuthorizationCode(code); + const disocrdUserResponse = await fetch( + "https://discord.com/api/users/@me", + { + headers: { + Authorization: `Bearer ${tokens.accessToken}`, + }, + } + ); + + const discordUser: DiscordUser = await disocrdUserResponse.json(); + + const existingUser = await context.locals.runtime.env.DB.prepare( + "SELECT * FROM user WHERE discord_id = ?1" + ) + .bind(discordUser.id) + .first(); + + if (existingUser) { + const session = await context.locals.lucia.createSession( + existingUser.id, + {} + ); + + const sessionCookie = context.locals.lucia.createSessionCookie( + session.id + ); + + context.cookies.set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + return context.redirect("/dashboard"); + } + + const userId = generateIdFromEntropySize(10); // 16 characters long + + await context.locals.runtime.env.DB.prepare( + "INSERT INTO user (id, discord_id, username, avatar, access_token, access_token_expiration, refresh_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + ) + .bind( + userId, + discordUser.id, + discordUser.username, + discordUser.avatar, + tokens.accessToken, + tokens.accessTokenExpiresAt.getTime(), + tokens.refreshToken + ) + .run(); + + const session = await context.locals.lucia.createSession(userId, {}); + const sessionCookie = context.locals.lucia.createSessionCookie(session.id); + context.cookies.set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + return context.redirect("/dashboard"); + } catch (e) { + console.error(e.message); + + // the specific error message depends on the provider + if (e instanceof OAuth2RequestError) { + // invalid code + return new Response(null, { + status: 400, + }); + } + return new Response(null, { + status: 500, + }); + } +} + +interface DiscordUser { + id: string; + username: string; + avatar: string; +} diff --git a/apps/website/src/pages/auth/login.astro b/apps/website/src/pages/auth/login.astro deleted file mode 100644 index 9598ec1..0000000 --- a/apps/website/src/pages/auth/login.astro +++ /dev/null @@ -1,15 +0,0 @@ ---- -import Layout from "~/layouts/Layout.astro"; - -export const prerender = true; ---- - - - - diff --git a/apps/website/src/pages/auth/login.ts b/apps/website/src/pages/auth/login.ts new file mode 100644 index 0000000..1e63010 --- /dev/null +++ b/apps/website/src/pages/auth/login.ts @@ -0,0 +1,20 @@ +import { generateState } from "arctic"; + +import type { APIContext } from "astro"; + +export async function GET(context: APIContext): Promise { + const state = generateState(); + const url = await context.locals.discord.createAuthorizationURL(state, { + scopes: ["identify", "guilds"], + }); + + context.cookies.set("discord_oauth_state", state, { + path: "/", + secure: import.meta.env.PROD, + httpOnly: true, + maxAge: 60 * 10, + sameSite: "lax", + }); + + return context.redirect(url.toString()); +} diff --git a/apps/website/src/pages/auth/logout.astro b/apps/website/src/pages/auth/logout.astro deleted file mode 100644 index eb95095..0000000 --- a/apps/website/src/pages/auth/logout.astro +++ /dev/null @@ -1,16 +0,0 @@ ---- -import Layout from "~/layouts/Layout.astro"; - -export const prerender = true; ---- - - - - diff --git a/apps/website/src/pages/auth/logout.ts b/apps/website/src/pages/auth/logout.ts new file mode 100644 index 0000000..4cbaaa7 --- /dev/null +++ b/apps/website/src/pages/auth/logout.ts @@ -0,0 +1,20 @@ +import type { APIContext } from "astro"; + +export async function GET(context: APIContext): Promise { + if (!context.locals.session) { + return new Response(null, { + status: 401, + }); + } + + await context.locals.lucia.invalidateSession(context.locals.session.id); + + const sessionCookie = context.locals.lucia.createBlankSessionCookie(); + context.cookies.set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); + + return context.redirect("/"); +} diff --git a/apps/website/src/pages/dashboard/guilds/[id].astro b/apps/website/src/pages/dashboard/guilds/[id].astro index 088bf70..cfadc13 100644 --- a/apps/website/src/pages/dashboard/guilds/[id].astro +++ b/apps/website/src/pages/dashboard/guilds/[id].astro @@ -1,15 +1,15 @@ --- import { getGuild } from "~/lib/guilds"; -import { getUser, isUserEligible } from "~/lib/user"; +import { isUserEligible } from "~/lib/user"; const { id } = Astro.params; -const user = await getUser(Astro.request); +const user = Astro.locals.user; if (!user || !id) { return Astro.redirect("/auth/login"); } -const guild = await getGuild(user, id); +const guild = await getGuild(user, id, Astro.locals.runtime.env); if (!guild) { return Astro.redirect("/dashboard"); } diff --git a/apps/website/src/pages/dashboard/index.astro b/apps/website/src/pages/dashboard/index.astro index 3165a2f..5533745 100644 --- a/apps/website/src/pages/dashboard/index.astro +++ b/apps/website/src/pages/dashboard/index.astro @@ -3,6 +3,10 @@ import Layout from "~/layouts/Layout.astro"; import Container from "~/components/Container.astro"; import GuildSelector from "~/components/preact/GuildSelector"; import UserInfo from "~/components/dashboard/UserInfo.astro"; + +if (!Astro.locals.user) { + return Astro.redirect("/auth/login"); +} --- diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 8543cd5..f35cf4f 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -1,11 +1,12 @@ { - "extends": "astro/tsconfigs/base", + "extends": "astro/tsconfigs/strictest", "compilerOptions": { "strictNullChecks": true, "allowJs": true, "baseUrl": ".", "paths": { "~/*": ["src/*"] - } + }, + "types": ["./src/env.d.ts"] } } diff --git a/apps/website/worker-configuration.d.ts b/apps/website/worker-configuration.d.ts new file mode 100644 index 0000000..94aec6d --- /dev/null +++ b/apps/website/worker-configuration.d.ts @@ -0,0 +1,11 @@ +// Generated by Wrangler on Sat Aug 03 2024 17:34:42 GMT+0200 (Central European Summer Time) +// by running `wrangler types` + +interface Env { + PRO: string; + DISCORD_CLIENT_ID: string; + DISCORD_CLIENT_SECRET: string; + DISCORD_BOT_TOKEN: string; + DISCORD_REDIRECT_URI: string; + DB: D1Database; +} diff --git a/apps/website/wrangler.toml b/apps/website/wrangler.toml new file mode 100644 index 0000000..644039c --- /dev/null +++ b/apps/website/wrangler.toml @@ -0,0 +1,6 @@ +name = "roles-bot" + +[[d1_databases]] +binding = "DB" # i.e. available in your Worker on env.DB +database_name = "roles-bot-dashboard" +database_id = "fdc6f902-9142-44bf-9be9-b38d351ffd27"