From 91e54793ffd7dc383aa3bb9ee8c84252d0cfa801 Mon Sep 17 00:00:00 2001 From: NotNite Date: Thu, 27 Apr 2023 13:47:30 -0400 Subject: [PATCH] rework authentication system --- .../migration.sql | 36 +++ prisma/schema.prisma | 4 +- src/app/api/changePassword/route.ts | 2 +- src/app/api/login/route.ts | 24 +- src/app/api/register/route.ts | 101 +------ src/app/api/update/route.ts | 2 +- src/app/login/page.tsx | 2 +- src/app/me/AboutMe.tsx | 2 +- src/app/me/page.tsx | 2 +- src/app/oauth/discord/login/route.ts | 4 +- src/app/oauth/discord/oauth.ts | 57 ---- src/app/oauth/discord/redirect/route.ts | 180 +++-------- src/app/oauth/github/login/route.ts | 3 +- src/app/oauth/github/oauth.ts | 41 --- src/app/oauth/github/redirect/route.ts | 160 +++------- src/app/register/RegisterForm.tsx | 11 +- src/app/register/page.tsx | 28 +- src/auth.ts | 112 ------- src/auth/AuthProvider.ts | 28 ++ src/auth/auth.ts | 283 ++++++++++++++++++ src/auth/discord.ts | 158 ++++++++++ src/auth/github.ts | 116 +++++++ src/prisma.ts | 62 ++-- src/schemas.ts | 18 ++ 24 files changed, 806 insertions(+), 630 deletions(-) create mode 100644 prisma/migrations/20230427162323_invalid_auth_tokens/migration.sql delete mode 100644 src/app/oauth/discord/oauth.ts delete mode 100644 src/app/oauth/github/oauth.ts delete mode 100644 src/auth.ts create mode 100644 src/auth/AuthProvider.ts create mode 100644 src/auth/auth.ts create mode 100644 src/auth/discord.ts create mode 100644 src/auth/github.ts diff --git a/prisma/migrations/20230427162323_invalid_auth_tokens/migration.sql b/prisma/migrations/20230427162323_invalid_auth_tokens/migration.sql new file mode 100644 index 0000000..1e7d076 --- /dev/null +++ b/prisma/migrations/20230427162323_invalid_auth_tokens/migration.sql @@ -0,0 +1,36 @@ +/* + Warnings: + + - The primary key for the `GitHubAuth` table will be changed. If it partially fails, the table could be left without primary key constraint. + - Added the required column `invalid` to the `DiscordAuth` table without a default value. This is not possible if the table is not empty. + - Added the required column `invalid` to the `GitHubAuth` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_DiscordAuth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER NOT NULL, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "invalid" BOOLEAN NOT NULL, + CONSTRAINT "DiscordAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_DiscordAuth" ("accessToken", "expiresAt", "id", "refreshToken", "userId") SELECT "accessToken", "expiresAt", "id", "refreshToken", "userId" FROM "DiscordAuth"; +DROP TABLE "DiscordAuth"; +ALTER TABLE "new_DiscordAuth" RENAME TO "DiscordAuth"; +CREATE UNIQUE INDEX "DiscordAuth_userId_key" ON "DiscordAuth"("userId"); +CREATE TABLE "new_GitHubAuth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER NOT NULL, + "accessToken" TEXT NOT NULL, + "invalid" BOOLEAN NOT NULL, + CONSTRAINT "GitHubAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_GitHubAuth" ("accessToken", "id", "userId") SELECT "accessToken", "id", "userId" FROM "GitHubAuth"; +DROP TABLE "GitHubAuth"; +ALTER TABLE "new_GitHubAuth" RENAME TO "GitHubAuth"; +CREATE UNIQUE INDEX "GitHubAuth_userId_key" ON "GitHubAuth"("userId"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fcf95a9..bf098bf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,13 +34,15 @@ model DiscordAuth { accessToken String refreshToken String expiresAt DateTime + invalid Boolean } model GitHubAuth { - id Int @id + id String @id user User @relation(fields: [userId], references: [id]) userId Int @unique accessToken String + invalid Boolean } diff --git a/src/app/api/changePassword/route.ts b/src/app/api/changePassword/route.ts index 03db8bf..2ef5152 100644 --- a/src/app/api/changePassword/route.ts +++ b/src/app/api/changePassword/route.ts @@ -1,4 +1,4 @@ -import { getUser } from "@/auth"; +import { getUser } from "@/auth/auth"; import { getUserInfo, setPassword, validateUser } from "@/ldap"; import { getLogger } from "@/logger"; diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 19a6f90..c22183b 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -1,5 +1,6 @@ +import { authTicketLogin } from "@/auth/auth"; import * as ldap from "@/ldap"; -import { createAuthTicket } from "@/auth"; +import { loginSchema } from "@/schemas"; type RequestBody = { username: string; @@ -7,22 +8,9 @@ type RequestBody = { }; export async function POST(request: Request) { - const { username, password } = (await request.json()) as RequestBody; - - if ( - username == undefined || - typeof username !== "string" || - password == undefined || - typeof password !== "string" - ) { - return new Response( - JSON.stringify({ - ok: false, - error: "invalidBody" - }), - { status: 400 } - ); - } + const { username, password } = await loginSchema.validate( + await request.json() + ); const valid = await ldap.validateUser(username, password); if (!valid) { @@ -35,7 +23,7 @@ export async function POST(request: Request) { ); } - const ticket = await createAuthTicket(username); + const [_, ticket] = await authTicketLogin(username); // not confident if we can set-cookie and I cba to try return new Response(JSON.stringify({ ok: true, ticket })); } diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 52b03ef..76697a3 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -1,104 +1,27 @@ import * as ldap from "@/ldap"; import prisma from "@/prisma"; -import { getUser } from "@/auth"; -import { getDiscordAvatar } from "@/app/oauth/discord/oauth"; -import { getGitHubAvatar } from "@/app/oauth/github/oauth"; +import { getUser } from "@/auth/auth"; import { getLogger } from "@/logger"; +import { registerServerSchema } from "@/schemas"; -type RequestBody = { - username: string; - displayName: string; - email: string; - password: string; - avatarBase64: string | null; -}; +const logger = getLogger("/api/register"); export async function POST(request: Request) { - const logger = getLogger("/api/register"); - const user = await getUser(); if (user == null) return new Response(null, { status: 401 }); - if (user.username !== null) { - if (!(await ldap.checkUserExists(user.username))) { - logger.warn( - { username: user.username }, - "user doesn't exist in ldap anymore" - ); - - user.username = null; - await prisma.user.update({ - where: { - id: user.id - }, - data: { - username: null - } - }); - } else { - logger.info(`user ${user.username} tried to register twice`); - // user already has an account, don't re-register - return new Response(null, { status: 403 }); - } - } - - const { username, displayName, email, password, avatarBase64 } = - (await request.json()) as RequestBody; - - // runtime type verification when :pleading: - if ( - username == undefined || - typeof username !== "string" || - displayName == undefined || - typeof displayName !== "string" || - email == undefined || - typeof email !== "string" || - password == undefined || - typeof password !== "string" - ) { - return new Response( - JSON.stringify({ - ok: false, - error: "invalidBody" - }), - { status: 400 } + // user already has an account, don't re-register + if (user.username != null) { + logger.info( + { username: user.username, id: user.id }, + `user tried to register twice` ); + return new Response(null, { status: 403 }); } - if (username.length < 1) { - return new Response( - JSON.stringify({ - ok: false, - error: "usernameShort" - }), - { status: 400 } - ); - } - - if (password.length < 12) { - return new Response( - JSON.stringify({ - ok: false, - error: "passwordShort" - }), - { status: 400 } - ); - } - - let avatarBuf: Buffer | null | undefined; - - if (avatarBase64 !== null && typeof avatarBase64 === "string") { - avatarBuf = Buffer.from(avatarBase64, "base64"); - if (avatarBuf.length > 1_000_000) { - return new Response( - JSON.stringify({ - ok: false, - error: "avatarBig" - }), - { status: 400 } - ); - } - } + const { username, displayName, email, password, avatar } = + await registerServerSchema.validate(await request.json()); + let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null; const users = await ldap.getUsers(); for (const user of users) { diff --git a/src/app/api/update/route.ts b/src/app/api/update/route.ts index 5fe0868..177b837 100644 --- a/src/app/api/update/route.ts +++ b/src/app/api/update/route.ts @@ -1,4 +1,4 @@ -import { getUser } from "@/auth"; +import { getUser } from "@/auth/auth"; import { getUserInfo, updateUser } from "@/ldap"; import { getLogger } from "@/logger"; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9c7605c..84277c5 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,7 @@ import styles from "@/app/page.module.css"; import React from "react"; import LoginForm from "./LoginForm"; -import { AuthState, getAuthState } from "@/auth"; +import { AuthState, getAuthState } from "@/auth/auth"; import { redirect } from "next/navigation"; export default async function Page() { diff --git a/src/app/me/AboutMe.tsx b/src/app/me/AboutMe.tsx index b921c5f..0a88b4f 100644 --- a/src/app/me/AboutMe.tsx +++ b/src/app/me/AboutMe.tsx @@ -2,7 +2,7 @@ "use client"; import { UserInfo } from "@/ldap"; -import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; +import React from "react"; import styles from "./AboutMe.module.css"; import AvatarChanger from "@/components/AvatarChanger"; import Input from "@/components/Input"; diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index 1b6731d..0e7bb70 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -1,4 +1,4 @@ -import { getUser } from "@/auth"; +import { getUser } from "@/auth/auth"; import { getUserInfo } from "@/ldap"; import AboutMe from "./AboutMe"; import { redirect } from "next/navigation"; diff --git a/src/app/oauth/discord/login/route.ts b/src/app/oauth/discord/login/route.ts index 4d4c897..e973d72 100644 --- a/src/app/oauth/discord/login/route.ts +++ b/src/app/oauth/discord/login/route.ts @@ -1,4 +1,4 @@ -import { discordRedirectUri } from "../oauth"; +import { DiscordAuthProvider } from "@/auth/discord"; import { v4 } from "uuid"; export async function GET(request: Request) { @@ -10,7 +10,7 @@ export async function GET(request: Request) { params.set("client_id", process.env.DISCORD_CLIENT_ID); params.set("scope", "guilds identify email"); params.set("state", state); - params.set("redirect_uri", discordRedirectUri()); + params.set("redirect_uri", DiscordAuthProvider.redirectUri); params.set("prompt", "consent"); url += "?" + params.toString(); diff --git a/src/app/oauth/discord/oauth.ts b/src/app/oauth/discord/oauth.ts deleted file mode 100644 index 9688726..0000000 --- a/src/app/oauth/discord/oauth.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { v4 } from "uuid"; - -export type DiscordAccessTokenResponse = { - access_token: string; - token_type: string; - expires_in: number; - refresh_token: string; - scope: string; -}; - -export type DiscordUserResponse = { - id: string; - avatar: string | null; - username: string; - email: string | null; -}; - -export type DiscordGuildResponse = { - id: string; -}; - -export function discordRedirectUri() { - return `${process.env.BASE_DOMAIN}oauth/discord/redirect`; -} - -export async function getDiscordUser(token: string) { - const req = await fetch("https://discord.com/api/users/@me", { - headers: { - Authorization: `Bearer ${token}` - } - }); - const res: DiscordUserResponse = await req.json(); - return res; -} - -export async function getDiscordGuilds(token: string) { - const req = await fetch("https://discord.com/api/users/@me/guilds", { - headers: { - Authorization: `Bearer ${token}` - } - }); - const res: DiscordGuildResponse[] = await req.json(); - return res.map((guild) => guild.id); -} - -export async function getDiscordAvatar(token: string) { - const req = await fetch("https://discord.com/api/users/@me", { - headers: { - Authorization: `Bearer ${token}` - } - }); - - const res: DiscordUserResponse = await req.json(); - if (res.avatar === null) return null; - const file = `https://cdn.discordapp.com/avatars/${res.id}/${res.avatar}.png`; - return file; -} diff --git a/src/app/oauth/discord/redirect/route.ts b/src/app/oauth/discord/redirect/route.ts index bdb9095..b3366b1 100644 --- a/src/app/oauth/discord/redirect/route.ts +++ b/src/app/oauth/discord/redirect/route.ts @@ -1,159 +1,49 @@ -import { URLSearchParams } from "url"; -import { - discordRedirectUri, - DiscordAccessTokenResponse, - getDiscordGuilds, - getDiscordUser, - getDiscordAvatar -} from "../oauth"; -import { cookies } from "next/dist/client/components/headers"; -import prisma from "@/prisma"; -import { v4 } from "uuid"; -import * as ldap from "@/ldap"; import { getLogger } from "@/logger"; -import { AuthState, getAuthState, getUser } from "@/auth"; +import { DiscordAuthProvider } from "@/auth/discord"; +import { + AuthState, + authTicketOAuth, + getAuthState, + getCode, + getUser +} from "@/auth/auth"; + +const logger = getLogger("/oauth/discord/redirect"); export async function GET(request: Request) { - const logger = getLogger("/oauth/discord/redirect"); + const code = await getCode(request); + if (code instanceof Response) return code; - let url = new URL(request.url); - let code = url.searchParams.get("code"); - let state = url.searchParams.get("state"); + const tokenBody = await DiscordAuthProvider.getToken(code); + if (tokenBody == null) throw "baby"; - if (code === null || state === null) { - logger.info("request made with missing code/state"); - return new Response("missing code/state", { status: 400 }); - } + const provider = new DiscordAuthProvider(tokenBody.access_token); + const id = await provider.getId(); + const permitted = await provider.isPermitted(); - const cookieStore = cookies(); - let cookieState = cookieStore.get("state"); - // prevent forgery - if (cookieState?.value !== state) { - logger.info( - "request made with invalid state - someone attempting forgery?" - ); - return new Response("state is invalid", { status: 400 }); - } - - let form = new URLSearchParams(); - form.append("client_id", process.env.DISCORD_CLIENT_ID); - form.append("client_secret", process.env.DISCORD_CLIENT_SECRET); - form.append("grant_type", "authorization_code"); - form.append("code", code); - form.append("redirect_uri", discordRedirectUri()); - - let tokenResponse = await fetch("https://discord.com/api/oauth2/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - body: form.toString() - }); - if (!tokenResponse.ok) { - logger.error("baby"); - throw "baby"; - } - - let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json(); - - const discordUser = await getDiscordUser(tokenBody.access_token); - const guilds = await getDiscordGuilds(tokenBody.access_token); - const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? []; - - let allowed = false; - for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true; - if (!allowed) { - logger.info({ id: discordUser.id }, "user tried to sign up"); + if (!permitted) { + logger.info({ id }, "user tried to sign up"); return new Response("not permitted to register account", { status: 403 }); } - let userId = null; + // If someone clicked register on the front page with an existing account, + // wire it to their user via the auth ticket + let gluestickId = null; const authState = await getAuthState(); if (authState === AuthState.LoggedIn) { const currentUser = await getUser(); - userId = currentUser?.id; + gluestickId = currentUser!.id; } - // - create the discord auth data in prisma, which will make the user if it doesn't exist - // - get the user from the discord auth data - // - either create a new auth ticket or invalidate the old one - // - update the user to point to the new auth ticket + const userId = await DiscordAuthProvider.update( + id, + tokenBody.access_token, + tokenBody.refresh_token, + new Date(Date.now() + tokenBody.expires_in * 1000), + gluestickId ?? undefined + ); - const discordAuth = await prisma.discordAuth.upsert({ - where: { - id: discordUser.id - }, - create: { - id: discordUser.id, - accessToken: tokenBody.access_token, - refreshToken: tokenBody.refresh_token, - expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000), - user: - userId != null - ? { connect: { id: userId } } - : { create: { username: null } } - }, - update: { - accessToken: tokenBody.access_token, - refreshToken: tokenBody.refresh_token, - expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000) - } - }); - - const user = await prisma.user.findFirst({ - where: { - id: discordAuth.userId - } - }); - - // check if user got deleted from ldap, same as /api/register - if ( - user !== null && - user.username !== null && - !(await ldap.checkUserExists(user.username)) - ) { - logger.warn( - { username: user.username }, - "user doesn't exist in ldap anymore" - ); - user.username = null; - await prisma.user.update({ - where: { - id: user.id - }, - data: { - username: null - } - }); - } - - const authTicket = await prisma.authTicket.upsert({ - where: { - userId: user!.id - }, - create: { - userId: user!.id, - ticket: v4(), - expiresAt: new Date(Date.now() + 86400000) - }, - update: { - ticket: v4(), - expiresAt: new Date(Date.now() + 86400000) - } - }); - - await prisma.user.update({ - where: { - id: user!.id - }, - data: { - authTicket: { - connect: { - id: authTicket.id - } - } - } - }); + const [user, authTicket] = await authTicketOAuth(userId); if (user?.username !== null) { return new Response(null, { @@ -165,11 +55,13 @@ export async function GET(request: Request) { }); } - const avatarUrl = await getDiscordAvatar(tokenBody.access_token); + const username = await provider.getDisplayName(); + const email = await provider.getEmail(); + const avatarUrl = await provider.getAvatar(); const query = new URLSearchParams(); - query.append("username", discordUser.username); - query.append("email", discordUser.email ?? ""); + query.append("username", username); + query.append("email", email ?? ""); query.append("avatar", avatarUrl ?? ""); return new Response(null, { diff --git a/src/app/oauth/github/login/route.ts b/src/app/oauth/github/login/route.ts index af39915..312704a 100644 --- a/src/app/oauth/github/login/route.ts +++ b/src/app/oauth/github/login/route.ts @@ -1,3 +1,4 @@ +import { GitHubAuthProvider } from "@/auth/github"; import { v4 } from "uuid"; export async function GET(request: Request) { @@ -8,7 +9,7 @@ export async function GET(request: Request) { params.set("client_id", process.env.GITHUB_CLIENT_ID); params.set("scope", "user"); params.set("state", state); - params.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`); + params.set("redirect_uri", GitHubAuthProvider.redirectUri); url += `?${params.toString()}`; diff --git a/src/app/oauth/github/oauth.ts b/src/app/oauth/github/oauth.ts deleted file mode 100644 index 4cc4d15..0000000 --- a/src/app/oauth/github/oauth.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type GitHubAccessTokenResponse = { - access_token: string; - scope: string; - token_type: string; -}; - -export type GitHubUserResponse = { - login: string; - id: number; - avatar_url: string; - email: string; -}; - -export async function getGitHubUser(token: string) { - const req = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${token}` - } - }); - const res: GitHubUserResponse = await req.json(); - return res; -} - -export async function checkInOrg(username: string) { - const req = await fetch( - `https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`, - { - headers: { - Authorization: `Bearer ${process.env.GITHUB_TOKEN}` - } - } - ); - - const res: GitHubUserResponse[] = await req.json(); - return res.some((user) => user.login === username); -} - -export async function getGitHubAvatar(token: string) { - const user = await getGitHubUser(token); - return user.avatar_url; -} diff --git a/src/app/oauth/github/redirect/route.ts b/src/app/oauth/github/redirect/route.ts index 1a9b04a..e61a8ff 100644 --- a/src/app/oauth/github/redirect/route.ts +++ b/src/app/oauth/github/redirect/route.ts @@ -1,141 +1,47 @@ import { getLogger } from "@/logger"; -import { cookies } from "next/dist/client/components/headers"; +import { GitHubAuthProvider } from "@/auth/github"; import { - checkInOrg, - getGitHubAvatar, - getGitHubUser, - GitHubAccessTokenResponse -} from "../oauth"; -import prisma from "@/prisma"; -import * as ldap from "@/ldap"; -import { v4 } from "uuid"; -import { AuthState, getAuthState, getUser } from "@/auth"; + AuthState, + authTicketOAuth, + getAuthState, + getCode, + getUser +} from "@/auth/auth"; const logger = getLogger("/oauth/github/redirect"); export async function GET(request: Request) { - let url = new URL(request.url); - let code = url.searchParams.get("code"); - let state = url.searchParams.get("state"); + const code = await getCode(request); + if (code instanceof Response) return code; - if (code === null || state === null) { - logger.info("request made with missing code/state"); - return new Response("missing code/state", { status: 400 }); - } + const tokenBody = await GitHubAuthProvider.getToken(code); + if (tokenBody == null) throw "baby"; - const cookieStore = cookies(); - let cookieState = cookieStore.get("state"); - // prevent forgery - if (cookieState?.value !== state) { - logger.info( - "request made with invalid state - someone attempting forgery?" - ); - return new Response("state is invalid", { status: 400 }); - } + const provider = new GitHubAuthProvider(tokenBody.access_token); + const id = await provider.getId(); + const permitted = await provider.isPermitted(); - let query = new URLSearchParams(); - query.set("client_id", process.env.GITHUB_CLIENT_ID); - query.set("client_secret", process.env.GITHUB_CLIENT_SECRET); - query.set("code", code); - query.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`); - - let tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`; - let tokenResponse = await fetch(tokenUrl, { - method: "POST", - headers: { - Accept: "application/json" - } - }); - - if (!tokenResponse.ok) { - logger.error("baby"); - throw "baby"; - } - - let resp: GitHubAccessTokenResponse = await tokenResponse.json(); - let accessToken = resp.access_token; - const githubUser = await getGitHubUser(accessToken); - const inOrg = await checkInOrg(githubUser.login); - - if (!inOrg) { - logger.info({ id: githubUser.login }, "user tried to sign up"); + if (!permitted) { + logger.info({ id }, "user tried to sign up"); return new Response("not permitted to register account", { status: 403 }); } - let userId = null; + // If someone clicked register on the front page with an existing account, + // wire it to their user via the auth ticket + let gluestickId = null; const authState = await getAuthState(); if (authState === AuthState.LoggedIn) { const currentUser = await getUser(); - userId = currentUser?.id; + gluestickId = currentUser!.id; } - const githubAuth = await prisma.gitHubAuth.upsert({ - where: { id: githubUser.id }, - create: { - id: githubUser.id, - accessToken, - user: - userId != null - ? { connect: { id: userId } } - : { create: { username: null } } - }, - update: { accessToken } - }); + const userId = await GitHubAuthProvider.update( + id, + tokenBody.access_token, + gluestickId ?? undefined + ); - const user = await prisma.user.findFirst({ - where: { - id: githubAuth.userId - } - }); - - // check if user got deleted from ldap, same as /api/register - if ( - user !== null && - user.username !== null && - !(await ldap.checkUserExists(user.username)) - ) { - logger.warn( - { username: user.username }, - "user doesn't exist in ldap anymore" - ); - user.username = null; - await prisma.user.update({ - where: { - id: user.id - }, - data: { - username: null - } - }); - } - - const authTicket = await prisma.authTicket.upsert({ - where: { - userId: user!.id - }, - create: { - userId: user!.id, - ticket: v4(), - expiresAt: new Date(Date.now() + 86400000) - }, - update: { - ticket: v4(), - expiresAt: new Date(Date.now() + 86400000) - } - }); - - await prisma.user.update({ - where: { - id: user!.id - }, - data: { - authTicket: { - connect: { - id: authTicket.id - } - } - } - }); + const [user, authTicket] = await authTicketOAuth(userId); if (user?.username !== null) { return new Response(null, { @@ -147,18 +53,20 @@ export async function GET(request: Request) { }); } - const avatarUrl = await getGitHubAvatar(accessToken); + const username = await provider.getDisplayName(); + const email = await provider.getEmail(); + const avatarUrl = await provider.getAvatar(); - const query2 = new URLSearchParams(); - query2.append("username", githubUser.login); - query2.append("email", githubUser.email); - query2.append("avatar", avatarUrl); + const query = new URLSearchParams(); + query.append("username", username); + query.append("email", email ?? ""); + query.append("avatar", avatarUrl ?? ""); return new Response(null, { status: 302, headers: { "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`, - Location: "/register?" + query2.toString() + Location: "/register?" + query.toString() } }); } diff --git a/src/app/register/RegisterForm.tsx b/src/app/register/RegisterForm.tsx index bef3e61..f878614 100644 --- a/src/app/register/RegisterForm.tsx +++ b/src/app/register/RegisterForm.tsx @@ -19,11 +19,13 @@ type RegisterResponse = { export default function RegisterForm({ initialDisplayName, initialEmail, - initialAvatarBase64 + initialAvatarBase64, + avatarSource }: { initialDisplayName?: string; initialEmail?: string; initialAvatarBase64?: string; + avatarSource: "Discord" | "GitHub" | null; }) { const [globalError, setGlobalError] = React.useState(null); const router = useRouter(); @@ -53,7 +55,7 @@ export default function RegisterForm({ displayName, email, password, - avatarBase64: avatar != null ? avatar.split(",")[1] : undefined + avatar: avatar != null ? avatar.split(",")[1] : undefined }) }); @@ -140,7 +142,10 @@ export default function RegisterForm({ fuck off

; } @@ -45,6 +59,7 @@ export default async function Page({ try { const jpg = await ensureJpg(buffer); initialAvatarBase64 = "data:image/jpeg;base64," + jpg; + avatarSource = tempAvatarSource; } catch (e) { console.error(e); } @@ -57,6 +72,7 @@ export default async function Page({ initialDisplayName={searchParams.displayName} initialEmail={searchParams.email} initialAvatarBase64={initialAvatarBase64} + avatarSource={avatarSource} /> ); diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index f32e47e..0000000 --- a/src/auth.ts +++ /dev/null @@ -1,112 +0,0 @@ -import prisma from "@/prisma"; -import { cookies } from "next/dist/client/components/headers"; -import { v4 } from "uuid"; -import * as ldap from "./ldap"; -import { getLogger } from "./logger"; - -const logger = getLogger("auth.ts"); - -export async function getUser() { - const cookieStore = cookies(); - const cookieTicket = cookieStore.get("ticket"); - if (cookieTicket == null) return null; - - const ticket = await prisma.authTicket.findFirst({ - where: { - ticket: cookieTicket?.value - } - }); - if (ticket == null) return null; - - const user = await prisma.user.findFirst({ - where: { - id: ticket.userId - } - }); - - if ( - user !== null && - user.username !== null && - !(await ldap.checkUserExists(user.username)) - ) { - logger.warn( - { username: user.username }, - "user doesn't exist in ldap anymore" - ); - - user.username = null; - await prisma.user.update({ - where: { - id: user.id - }, - data: { - username: null - } - }); - } - - return user; -} - -export async function createAuthTicket(username: string) { - let user = await prisma.user.findFirst({ - where: { - username: username - } - }); - - // It's possible we haven't made a user yet (already existing accounts) - if (user === null) { - user = await prisma.user.create({ - data: { - username: username - } - }); - } - - const authTicket = await prisma.authTicket.upsert({ - where: { - userId: user!.id - }, - create: { - userId: user!.id, - ticket: v4(), - expiresAt: new Date(Date.now() + 86400000) - }, - update: { - ticket: v4(), - expiresAt: new Date(Date.now() + 86400000) - } - }); - - await prisma.user.update({ - where: { - id: user!.id - }, - data: { - authTicket: { - connect: { - id: authTicket.id - } - } - } - }); - - return authTicket.ticket; -} - -export enum AuthState { - LoggedOut, - Registering, - LoggedIn -} - -export async function getAuthState() { - const user = await getUser(); - if (user === null) return AuthState.LoggedOut; - - const info = ldap.getUserInfo(user); - if (info === null) return AuthState.Registering; - - return AuthState.LoggedIn; -} diff --git a/src/auth/AuthProvider.ts b/src/auth/AuthProvider.ts new file mode 100644 index 0000000..1fe81eb --- /dev/null +++ b/src/auth/AuthProvider.ts @@ -0,0 +1,28 @@ +export abstract class AuthProvider { + protected readonly accessToken: string; + + constructor(accessToken: string) { + this.accessToken = accessToken; + } + + abstract isPermitted(): Promise; + abstract getId(): Promise; + + // this difference only really matters for discordd + // display name: + // - discord: username + // - github: username + // username: + // - discord: username#discriminator + // - github: username + abstract getDisplayName(): Promise; + abstract getUsername(): Promise; + + // these two aren't null for github + abstract getAvatar(): Promise; + abstract getEmail(): Promise; + + static get redirectUri(): string { + throw new Error("Not implemented"); + } +} diff --git a/src/auth/auth.ts b/src/auth/auth.ts new file mode 100644 index 0000000..628675f --- /dev/null +++ b/src/auth/auth.ts @@ -0,0 +1,283 @@ +import prisma from "@/prisma"; +import { cookies } from "next/dist/client/components/headers"; +import { v4 } from "uuid"; +import * as ldap from "../ldap"; +import { getLogger } from "../logger"; +import { AuthTicket, User } from "@prisma/client"; +import { DiscordAuthProvider } from "./discord"; +import { GitHubAuthProvider } from "./github"; +import { AuthProvider } from "./AuthProvider"; + +const logger = getLogger("auth.ts"); + +export class GluestickUser { + private readonly dbTicket: AuthTicket; + private dbUser: User; + + username: string | null; + + constructor(ticket: AuthTicket, user: User) { + this.dbTicket = ticket; + this.dbUser = user; + + this.username = user?.username; + } + + get id(): number { + return this.dbUser.id; + } + + get isRegistering(): boolean { + return this.username == null; + } + + get authTicket(): string { + return this.dbTicket.ticket; + } + + async updateUsername(username?: string) { + const user = await prisma.user.update({ + where: { + id: this.dbUser.id + }, + data: { + username + } + }); + + this.dbUser = user; + this.username = username ?? null; + } + + async getDiscord(): Promise { + const discord = await prisma.discordAuth.findFirst({ + where: { + userId: this.dbUser.id + } + }); + + return discord === null + ? null + : new DiscordAuthProvider(discord.accessToken); + } + + async getGitHub(): Promise { + const github = await prisma.gitHubAuth.findFirst({ + where: { + userId: this.dbUser.id + } + }); + + return github === null ? null : new GitHubAuthProvider(github.accessToken); + } + + async getAuthProviders(): Promise { + const providers = []; + + const discord = await this.getDiscord(); + if (discord !== null) providers.push(discord); + + const github = await this.getGitHub(); + if (github !== null) providers.push(github); + + return providers; + } +} + +async function getAuthTicket() { + const cookieStore = cookies(); + const cookieTicket = cookieStore.get("ticket"); + if (cookieTicket == null) return null; + + const ticket = await prisma.authTicket.findFirst({ + where: { + ticket: cookieTicket.value + } + }); + return ticket ?? null; +} + +export async function getUser(ticket?: AuthTicket) { + if (ticket == null) { + let newTicket = await getAuthTicket(); + if (newTicket == null) return null; + ticket = newTicket; + } + + const user = await prisma.user.findFirst({ + where: { + id: ticket.userId + } + }); + + if ( + user !== null && + user.username !== null && + !(await ldap.checkUserExists(user.username)) + ) { + logger.warn( + { username: user.username }, + "user doesn't exist in ldap anymore" + ); + + user.username = null; + await prisma.user.update({ + where: { + id: user.id + }, + data: { + username: null + } + }); + } + + if (user === null) return null; + + return new GluestickUser(ticket, user); +} + +export enum AuthState { + LoggedOut, // no auth token + Registering, // has an auth token but no LDAP + LoggedIn // has an auth token and LDAP +} + +export async function getAuthState() { + const ticket = await getAuthTicket(); + if (ticket == null) return AuthState.LoggedOut; + + const user = await getUser(ticket); + if (user == null) return AuthState.Registering; + + return AuthState.LoggedIn; +} + +export async function getCode(request: Request) { + let url = new URL(request.url); + let code = url.searchParams.get("code"); + let state = url.searchParams.get("state"); + + if (code === null || state === null) { + logger.info("request made with missing code/state"); + return new Response("missing code/state", { status: 400 }); + } + + const cookieStore = cookies(); + let cookieState = cookieStore.get("state"); + // prevent forgery + if (cookieState?.value !== state) { + logger.info( + "request made with invalid state - someone attempting forgery?" + ); + return new Response("state is invalid", { status: 400 }); + } + + return code; +} + +export async function authTicketOAuth(id: number): Promise<[User, AuthTicket]> { + // Handle the case in which we already have a user + // (clicked on oauth button with existing ticket) + const user = (await prisma.user.findFirst({ + where: { + id: id + } + }))!; + + // check if user got deleted from ldap, same as /api/register + if (user.username !== null && !(await ldap.checkUserExists(user.username))) { + logger.warn( + { username: user.username }, + "user doesn't exist in ldap anymore" + ); + user.username = null; + await prisma.user.update({ + where: { + id: user.id + }, + data: { + username: null + } + }); + } + + const authTicket = await prisma.authTicket.upsert({ + where: { + userId: user!.id + }, + create: { + userId: user!.id, + ticket: v4(), + expiresAt: new Date(Date.now() + 86400000) + }, + update: { + ticket: v4(), + expiresAt: new Date(Date.now() + 86400000) + } + }); + + await prisma.user.update({ + where: { + id: user!.id + }, + data: { + authTicket: { + connect: { + id: authTicket.id + } + } + } + }); + + return [user, authTicket]; +} + +export async function authTicketLogin( + username: string +): Promise<[User, AuthTicket]> { + let user = await prisma.user.findFirst({ + where: { + username + } + }); + + // maybe our DB got reset? let's just create a new user + if (user === null) { + logger.warn({ username }, "user exists in ldap, but not database"); + user = await prisma.user.create({ + data: { + username + } + }); + } + + const authTicket = await prisma.authTicket.upsert({ + where: { + userId: user!.id + }, + create: { + userId: user!.id, + ticket: v4(), + expiresAt: new Date(Date.now() + 86400000) + }, + update: { + ticket: v4(), + expiresAt: new Date(Date.now() + 86400000) + } + }); + + await prisma.user.update({ + where: { + id: user!.id + }, + data: { + authTicket: { + connect: { + id: authTicket.id + } + } + } + }); + + return [user, authTicket]; +} diff --git a/src/auth/discord.ts b/src/auth/discord.ts new file mode 100644 index 0000000..9d0d745 --- /dev/null +++ b/src/auth/discord.ts @@ -0,0 +1,158 @@ +import { AuthProvider } from "./AuthProvider"; +import prisma from "@/prisma"; + +export type DiscordAccessTokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + scope: string; +}; + +type DiscordUserResponse = { + id: string; + avatar: string | null; + username: string; + email: string | null; + discriminator: string; +}; + +type DiscordGuildResponse = { + id: string; +}; + +export class DiscordAuthProvider extends AuthProvider { + private async getMe(): Promise { + const req = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + const res: DiscordUserResponse = await req.json(); + return res; + } + + async isPermitted(): Promise { + const req = await fetch("https://discord.com/api/users/@me/guilds", { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + + const res: DiscordGuildResponse[] = await req.json(); + const guilds = res.map((guild) => guild.id); + const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? []; + + let allowed = false; + for (const guild of allowedGuilds) { + if (guilds.includes(guild)) allowed = true; + } + return allowed; + } + + async getDisplayName(): Promise { + const me = await this.getMe(); + return me.username; + } + + async getUsername(): Promise { + const me = await this.getMe(); + return me.username + "#" + me.discriminator; + } + + async getId(): Promise { + const me = await this.getMe(); + return me.id; + } + + async getAvatar(): Promise { + const me = await this.getMe(); + return me.avatar !== null + ? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png` + : null; + } + + async getEmail(): Promise { + const me = await this.getMe(); + return me.email; + } + + static get redirectUri(): string { + return `${process.env.BASE_DOMAIN}oauth/discord/redirect`; + } + + static async getToken( + code: string + ): Promise { + const form = new URLSearchParams(); + form.append("client_id", process.env.DISCORD_CLIENT_ID); + form.append("client_secret", process.env.DISCORD_CLIENT_SECRET); + form.append("grant_type", "authorization_code"); + form.append("code", code); + form.append("redirect_uri", this.redirectUri); + + const tokenResponse = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: form.toString() + }); + + if (!tokenResponse.ok) return null; + return await tokenResponse.json(); + } + + static async refreshToken( + refreshToken: string + ): Promise { + const req = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + client_id: process.env.DISCORD_CLIENT_ID, + client_secret: process.env.DISCORD_CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: refreshToken + }).toString() + }); + + if (!req.ok) return null; + return await req.json(); + } + + static async update( + id: string, + accessToken: string, + refreshToken: string, + expiresAt: Date, + userId?: number + ): Promise { + const a = await prisma.discordAuth.upsert({ + where: { + id + }, + create: { + id, + accessToken, + refreshToken, + expiresAt, + user: + userId != null + ? { connect: { id: userId } } + : { create: { username: null } }, + invalid: false + }, + update: { + accessToken, + refreshToken, + expiresAt, + invalid: false + } + }); + + return a.userId; + } +} diff --git a/src/auth/github.ts b/src/auth/github.ts new file mode 100644 index 0000000..8460109 --- /dev/null +++ b/src/auth/github.ts @@ -0,0 +1,116 @@ +import { AuthProvider } from "./AuthProvider"; +import prisma from "@/prisma"; + +export type GitHubAccessTokenResponse = { + access_token: string; + scope: string; + token_type: string; +}; + +type GitHubUserResponse = { + login: string; + id: number; + avatar_url: string; + email: string; +}; + +export class GitHubAuthProvider extends AuthProvider { + private async getMe(): Promise { + const req = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${this.accessToken}` + } + }); + const res: GitHubUserResponse = await req.json(); + return res; + } + + async isPermitted(): Promise { + const me = await this.getMe(); + const req = await fetch( + `https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`, + { + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}` + } + } + ); + const res: GitHubUserResponse[] = await req.json(); + return res.some((user) => user.login === me.login); + } + + async getDisplayName(): Promise { + const me = await this.getMe(); + return me.login; + } + + async getUsername(): Promise { + return this.getDisplayName(); + } + + async getId(): Promise { + const me = await this.getMe(); + return me.id.toString(); + } + + async getAvatar(): Promise { + const me = await this.getMe(); + return me.avatar_url; + } + + async getEmail(): Promise { + const me = await this.getMe(); + return me.email; + } + + static get redirectUri(): string { + return `${process.env.BASE_DOMAIN}oauth/github/redirect`; + } + + static async getToken( + code: string + ): Promise { + const query = new URLSearchParams(); + query.set("client_id", process.env.GITHUB_CLIENT_ID); + query.set("client_secret", process.env.GITHUB_CLIENT_SECRET); + query.set("code", code); + query.set("redirect_uri", this.redirectUri); + + const tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`; + const tokenResponse = await fetch(tokenUrl, { + method: "POST", + headers: { + Accept: "application/json" + } + }); + + if (!tokenResponse.ok) return null; + return await tokenResponse.json(); + } + static async update( + id: string, + accessToken: string, + userId?: number + ): Promise { + const a = await prisma.gitHubAuth.upsert({ + where: { + id + }, + create: { + id, + accessToken, + user: + userId != null + ? { connect: { id: userId } } + : { create: { username: null } }, + invalid: false + }, + update: { + accessToken, + invalid: false + } + }); + + return a.userId; + } +} diff --git a/src/prisma.ts b/src/prisma.ts index 3ce0363..b989874 100644 --- a/src/prisma.ts +++ b/src/prisma.ts @@ -1,5 +1,8 @@ import { Prisma, PrismaClient } from "@prisma/client"; -import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth"; +import { DiscordAuthProvider } from "./auth/discord"; +import { getLogger } from "./logger"; + +const logger = getLogger("prisma.ts"); async function refreshDiscordTokens(prisma: PrismaClient) { // refresh 6 hours before expiry @@ -9,36 +12,45 @@ async function refreshDiscordTokens(prisma: PrismaClient) { where: { expiresAt: { lte: new Date(Date.now() + refreshWindow) - } + }, + invalid: false } }); for (const discordAuth of discordAuths) { - const req = await fetch("https://discord.com/api/oauth2/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded" - }, - body: new URLSearchParams({ - client_id: process.env.DISCORD_CLIENT_ID, - client_secret: process.env.DISCORD_CLIENT_SECRET, - grant_type: "refresh_token", - refresh_token: discordAuth.refreshToken - }).toString() - }); + const data = await DiscordAuthProvider.refreshToken( + discordAuth.refreshToken + ); - const res: DiscordAccessTokenResponse = await req.json(); + if (data === null) { + logger.warn( + { + user: discordAuth.userId, + id: discordAuth.id + }, + "failed to refresh discord token" + ); - await prisma.discordAuth.update({ - where: { - id: discordAuth.id - }, - data: { - accessToken: res.access_token, - refreshToken: res.refresh_token, - expiresAt: new Date(Date.now() + res.expires_in * 1000) - } - }); + await prisma.discordAuth.update({ + where: { + id: discordAuth.id + }, + data: { + invalid: true + } + }); + } else { + await prisma.discordAuth.update({ + where: { + id: discordAuth.id + }, + data: { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: new Date(Date.now() + data.expires_in * 1000) + } + }); + } } } diff --git a/src/schemas.ts b/src/schemas.ts index 16278fc..846400c 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -87,3 +87,21 @@ export interface PasswordUpdateFormValues { newPassword: string; confirmPassword: string; } + +// Types specific to the server, because sometimes we omit fields (like confirmPassword) +export const registerServerSchema: Yup.Schema = + Yup.object().shape({ + username: USERNAME, + displayName: DISPLAY_NAME, + email: EMAIL, + password: PASSWORD, + avatar: AVATAR + }); + +export interface RegisterServerFormValues { + username: string; + displayName: string; + email: string; + password: string; + avatar?: string; +}