1
0
Fork 0

rework authentication system

This commit is contained in:
Julian 2023-04-27 13:47:30 -04:00
parent 8d428d90a8
commit 91e54793ff
Signed by untrusted user: NotNite
GPG Key ID: BD91A5402CCEB08A
24 changed files with 806 additions and 630 deletions

View File

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

View File

@ -34,13 +34,15 @@ model DiscordAuth {
accessToken String accessToken String
refreshToken String refreshToken String
expiresAt DateTime expiresAt DateTime
invalid Boolean
} }
model GitHubAuth { model GitHubAuth {
id Int @id id String @id
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId Int @unique userId Int @unique
accessToken String accessToken String
invalid Boolean
} }

View File

@ -1,4 +1,4 @@
import { getUser } from "@/auth"; import { getUser } from "@/auth/auth";
import { getUserInfo, setPassword, validateUser } from "@/ldap"; import { getUserInfo, setPassword, validateUser } from "@/ldap";
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";

View File

@ -1,5 +1,6 @@
import { authTicketLogin } from "@/auth/auth";
import * as ldap from "@/ldap"; import * as ldap from "@/ldap";
import { createAuthTicket } from "@/auth"; import { loginSchema } from "@/schemas";
type RequestBody = { type RequestBody = {
username: string; username: string;
@ -7,22 +8,9 @@ type RequestBody = {
}; };
export async function POST(request: Request) { export async function POST(request: Request) {
const { username, password } = (await request.json()) as RequestBody; const { username, password } = await loginSchema.validate(
await request.json()
if ( );
username == undefined ||
typeof username !== "string" ||
password == undefined ||
typeof password !== "string"
) {
return new Response(
JSON.stringify({
ok: false,
error: "invalidBody"
}),
{ status: 400 }
);
}
const valid = await ldap.validateUser(username, password); const valid = await ldap.validateUser(username, password);
if (!valid) { 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 // not confident if we can set-cookie and I cba to try
return new Response(JSON.stringify({ ok: true, ticket })); return new Response(JSON.stringify({ ok: true, ticket }));
} }

View File

@ -1,104 +1,27 @@
import * as ldap from "@/ldap"; import * as ldap from "@/ldap";
import prisma from "@/prisma"; import prisma from "@/prisma";
import { getUser } from "@/auth"; import { getUser } from "@/auth/auth";
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
import { getGitHubAvatar } from "@/app/oauth/github/oauth";
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";
import { registerServerSchema } from "@/schemas";
type RequestBody = { const logger = getLogger("/api/register");
username: string;
displayName: string;
email: string;
password: string;
avatarBase64: string | null;
};
export async function POST(request: Request) { export async function POST(request: Request) {
const logger = getLogger("/api/register");
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 }); if (user == null) return new Response(null, { status: 401 });
if (user.username !== null) { // user already has an account, don't re-register
if (!(await ldap.checkUserExists(user.username))) { if (user.username != null) {
logger.warn( logger.info(
{ username: user.username }, { username: user.username, id: user.id },
"user doesn't exist in ldap anymore" `user tried to register twice`
);
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 }
); );
return new Response(null, { status: 403 });
} }
if (username.length < 1) { const { username, displayName, email, password, avatar } =
return new Response( await registerServerSchema.validate(await request.json());
JSON.stringify({ let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null;
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 users = await ldap.getUsers(); const users = await ldap.getUsers();
for (const user of users) { for (const user of users) {

View File

@ -1,4 +1,4 @@
import { getUser } from "@/auth"; import { getUser } from "@/auth/auth";
import { getUserInfo, updateUser } from "@/ldap"; import { getUserInfo, updateUser } from "@/ldap";
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";

View File

@ -1,7 +1,7 @@
import styles from "@/app/page.module.css"; import styles from "@/app/page.module.css";
import React from "react"; import React from "react";
import LoginForm from "./LoginForm"; import LoginForm from "./LoginForm";
import { AuthState, getAuthState } from "@/auth"; import { AuthState, getAuthState } from "@/auth/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function Page() { export default async function Page() {

View File

@ -2,7 +2,7 @@
"use client"; "use client";
import { UserInfo } from "@/ldap"; import { UserInfo } from "@/ldap";
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import React from "react";
import styles from "./AboutMe.module.css"; import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import Input from "@/components/Input"; import Input from "@/components/Input";

View File

@ -1,4 +1,4 @@
import { getUser } from "@/auth"; import { getUser } from "@/auth/auth";
import { getUserInfo } from "@/ldap"; import { getUserInfo } from "@/ldap";
import AboutMe from "./AboutMe"; import AboutMe from "./AboutMe";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";

View File

@ -1,4 +1,4 @@
import { discordRedirectUri } from "../oauth"; import { DiscordAuthProvider } from "@/auth/discord";
import { v4 } from "uuid"; import { v4 } from "uuid";
export async function GET(request: Request) { 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("client_id", process.env.DISCORD_CLIENT_ID);
params.set("scope", "guilds identify email"); params.set("scope", "guilds identify email");
params.set("state", state); params.set("state", state);
params.set("redirect_uri", discordRedirectUri()); params.set("redirect_uri", DiscordAuthProvider.redirectUri);
params.set("prompt", "consent"); params.set("prompt", "consent");
url += "?" + params.toString(); url += "?" + params.toString();

View File

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

View File

@ -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 { 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) { 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); const tokenBody = await DiscordAuthProvider.getToken(code);
let code = url.searchParams.get("code"); if (tokenBody == null) throw "baby";
let state = url.searchParams.get("state");
if (code === null || state === null) { const provider = new DiscordAuthProvider(tokenBody.access_token);
logger.info("request made with missing code/state"); const id = await provider.getId();
return new Response("missing code/state", { status: 400 }); const permitted = await provider.isPermitted();
}
const cookieStore = cookies(); if (!permitted) {
let cookieState = cookieStore.get("state"); logger.info({ id }, "user tried to sign up");
// 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");
return new Response("not permitted to register account", { status: 403 }); 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(); const authState = await getAuthState();
if (authState === AuthState.LoggedIn) { if (authState === AuthState.LoggedIn) {
const currentUser = await getUser(); 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 const userId = await DiscordAuthProvider.update(
// - get the user from the discord auth data id,
// - either create a new auth ticket or invalidate the old one tokenBody.access_token,
// - update the user to point to the new auth ticket tokenBody.refresh_token,
new Date(Date.now() + tokenBody.expires_in * 1000),
gluestickId ?? undefined
);
const discordAuth = await prisma.discordAuth.upsert({ const [user, authTicket] = await authTicketOAuth(userId);
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
}
}
}
});
if (user?.username !== null) { if (user?.username !== null) {
return new Response(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(); const query = new URLSearchParams();
query.append("username", discordUser.username); query.append("username", username);
query.append("email", discordUser.email ?? ""); query.append("email", email ?? "");
query.append("avatar", avatarUrl ?? ""); query.append("avatar", avatarUrl ?? "");
return new Response(null, { return new Response(null, {

View File

@ -1,3 +1,4 @@
import { GitHubAuthProvider } from "@/auth/github";
import { v4 } from "uuid"; import { v4 } from "uuid";
export async function GET(request: Request) { 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("client_id", process.env.GITHUB_CLIENT_ID);
params.set("scope", "user"); params.set("scope", "user");
params.set("state", state); params.set("state", state);
params.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`); params.set("redirect_uri", GitHubAuthProvider.redirectUri);
url += `?${params.toString()}`; url += `?${params.toString()}`;

View File

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

View File

@ -1,141 +1,47 @@
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";
import { cookies } from "next/dist/client/components/headers"; import { GitHubAuthProvider } from "@/auth/github";
import { import {
checkInOrg, AuthState,
getGitHubAvatar, authTicketOAuth,
getGitHubUser, getAuthState,
GitHubAccessTokenResponse getCode,
} from "../oauth"; getUser
import prisma from "@/prisma"; } from "@/auth/auth";
import * as ldap from "@/ldap";
import { v4 } from "uuid";
import { AuthState, getAuthState, getUser } from "@/auth";
const logger = getLogger("/oauth/github/redirect"); const logger = getLogger("/oauth/github/redirect");
export async function GET(request: Request) { export async function GET(request: Request) {
let url = new URL(request.url); const code = await getCode(request);
let code = url.searchParams.get("code"); if (code instanceof Response) return code;
let state = url.searchParams.get("state");
if (code === null || state === null) { const tokenBody = await GitHubAuthProvider.getToken(code);
logger.info("request made with missing code/state"); if (tokenBody == null) throw "baby";
return new Response("missing code/state", { status: 400 });
}
const cookieStore = cookies(); const provider = new GitHubAuthProvider(tokenBody.access_token);
let cookieState = cookieStore.get("state"); const id = await provider.getId();
// prevent forgery const permitted = await provider.isPermitted();
if (cookieState?.value !== state) {
logger.info(
"request made with invalid state - someone attempting forgery?"
);
return new Response("state is invalid", { status: 400 });
}
let query = new URLSearchParams(); if (!permitted) {
query.set("client_id", process.env.GITHUB_CLIENT_ID); logger.info({ id }, "user tried to sign up");
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");
return new Response("not permitted to register account", { status: 403 }); 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(); const authState = await getAuthState();
if (authState === AuthState.LoggedIn) { if (authState === AuthState.LoggedIn) {
const currentUser = await getUser(); const currentUser = await getUser();
userId = currentUser?.id; gluestickId = currentUser!.id;
} }
const githubAuth = await prisma.gitHubAuth.upsert({ const userId = await GitHubAuthProvider.update(
where: { id: githubUser.id }, id,
create: { tokenBody.access_token,
id: githubUser.id, gluestickId ?? undefined
accessToken, );
user:
userId != null
? { connect: { id: userId } }
: { create: { username: null } }
},
update: { accessToken }
});
const user = await prisma.user.findFirst({ const [user, authTicket] = await authTicketOAuth(userId);
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
}
}
}
});
if (user?.username !== null) { if (user?.username !== null) {
return new Response(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(); const query = new URLSearchParams();
query2.append("username", githubUser.login); query.append("username", username);
query2.append("email", githubUser.email); query.append("email", email ?? "");
query2.append("avatar", avatarUrl); query.append("avatar", avatarUrl ?? "");
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`, "Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
Location: "/register?" + query2.toString() Location: "/register?" + query.toString()
} }
}); });
} }

View File

@ -19,11 +19,13 @@ type RegisterResponse = {
export default function RegisterForm({ export default function RegisterForm({
initialDisplayName, initialDisplayName,
initialEmail, initialEmail,
initialAvatarBase64 initialAvatarBase64,
avatarSource
}: { }: {
initialDisplayName?: string; initialDisplayName?: string;
initialEmail?: string; initialEmail?: string;
initialAvatarBase64?: string; initialAvatarBase64?: string;
avatarSource: "Discord" | "GitHub" | null;
}) { }) {
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter(); const router = useRouter();
@ -53,7 +55,7 @@ export default function RegisterForm({
displayName, displayName,
email, email,
password, password,
avatarBase64: avatar != null ? avatar.split(",")[1] : undefined avatar: avatar != null ? avatar.split(",")[1] : undefined
}) })
}); });
@ -140,7 +142,10 @@ export default function RegisterForm({
<Input <Input
hint={ hint={
"This image will automatically be used as your avatar with supported services - maximum 1 MB. " (avatarSource != null && avatarSource !== "Discord"
? `We found your avatar from ${avatarSource}, but you can change it if you'd like.`
: "") +
" This will automatically be used as your avatar with supported services - maximum 1 MB. "
} }
type="file" type="file"
name="avatar" name="avatar"

View File

@ -4,12 +4,23 @@ import RegisterForm from "./RegisterForm";
import { redirect, useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import { ensureJpg } from "@/image"; import { ensureJpg } from "@/image";
function avatarUrlAllowed(url: URL): boolean { function avatarUrlSource(url: URL): "Discord" | "GitHub" | null {
let github = url.hostname === "avatars.githubusercontent.com"; if (
let discord = url.hostname === "cdn.discordapp.com"; url.hostname === "cdn.discordapp.com" &&
url.pathname.startsWith("/avatars")
) {
return "Discord";
}
if (discord && !url.pathname.startsWith("/avatars")) return false; if (url.hostname === "avatars.githubusercontent.com") {
return github || discord; return "GitHub";
}
return null;
}
function avatarUrlAllowed(url: URL): boolean {
return avatarUrlSource(url) !== null;
} }
export default async function Page({ export default async function Page({
@ -29,10 +40,13 @@ export default async function Page({
} }
let initialAvatarBase64 = undefined; let initialAvatarBase64 = undefined;
let avatarSource = null;
if (searchParams.avatar != null && searchParams.avatar !== "") { if (searchParams.avatar != null && searchParams.avatar !== "") {
const url = new URL(searchParams.avatar); const url = new URL(searchParams.avatar);
let tempAvatarSource = avatarUrlSource(url);
if (!avatarUrlAllowed(url)) { // prevent people from getting the server to fetch() arbitrary URLs
if (tempAvatarSource == null) {
return <p>fuck off</p>; return <p>fuck off</p>;
} }
@ -45,6 +59,7 @@ export default async function Page({
try { try {
const jpg = await ensureJpg(buffer); const jpg = await ensureJpg(buffer);
initialAvatarBase64 = "data:image/jpeg;base64," + jpg; initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
avatarSource = tempAvatarSource;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@ -57,6 +72,7 @@ export default async function Page({
initialDisplayName={searchParams.displayName} initialDisplayName={searchParams.displayName}
initialEmail={searchParams.email} initialEmail={searchParams.email}
initialAvatarBase64={initialAvatarBase64} initialAvatarBase64={initialAvatarBase64}
avatarSource={avatarSource}
/> />
</main> </main>
); );

View File

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

28
src/auth/AuthProvider.ts Normal file
View File

@ -0,0 +1,28 @@
export abstract class AuthProvider {
protected readonly accessToken: string;
constructor(accessToken: string) {
this.accessToken = accessToken;
}
abstract isPermitted(): Promise<boolean>;
abstract getId(): Promise<string>;
// this difference only really matters for discordd
// display name:
// - discord: username
// - github: username
// username:
// - discord: username#discriminator
// - github: username
abstract getDisplayName(): Promise<string>;
abstract getUsername(): Promise<string>;
// these two aren't null for github
abstract getAvatar(): Promise<string | null>;
abstract getEmail(): Promise<string | null>;
static get redirectUri(): string {
throw new Error("Not implemented");
}
}

283
src/auth/auth.ts Normal file
View File

@ -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<DiscordAuthProvider | null> {
const discord = await prisma.discordAuth.findFirst({
where: {
userId: this.dbUser.id
}
});
return discord === null
? null
: new DiscordAuthProvider(discord.accessToken);
}
async getGitHub(): Promise<GitHubAuthProvider | null> {
const github = await prisma.gitHubAuth.findFirst({
where: {
userId: this.dbUser.id
}
});
return github === null ? null : new GitHubAuthProvider(github.accessToken);
}
async getAuthProviders(): Promise<AuthProvider[]> {
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];
}

158
src/auth/discord.ts Normal file
View File

@ -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<DiscordUserResponse> {
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<boolean> {
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<string> {
const me = await this.getMe();
return me.username;
}
async getUsername(): Promise<string> {
const me = await this.getMe();
return me.username + "#" + me.discriminator;
}
async getId(): Promise<string> {
const me = await this.getMe();
return me.id;
}
async getAvatar(): Promise<string | null> {
const me = await this.getMe();
return me.avatar !== null
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png`
: null;
}
async getEmail(): Promise<string | null> {
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<DiscordAccessTokenResponse | null> {
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<DiscordAccessTokenResponse | null> {
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<number> {
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;
}
}

116
src/auth/github.ts Normal file
View File

@ -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<GitHubUserResponse> {
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<boolean> {
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<string> {
const me = await this.getMe();
return me.login;
}
async getUsername(): Promise<string> {
return this.getDisplayName();
}
async getId(): Promise<string> {
const me = await this.getMe();
return me.id.toString();
}
async getAvatar(): Promise<string | null> {
const me = await this.getMe();
return me.avatar_url;
}
async getEmail(): Promise<string | null> {
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<GitHubAccessTokenResponse | null> {
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<number> {
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;
}
}

View File

@ -1,5 +1,8 @@
import { Prisma, PrismaClient } from "@prisma/client"; 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) { async function refreshDiscordTokens(prisma: PrismaClient) {
// refresh 6 hours before expiry // refresh 6 hours before expiry
@ -9,36 +12,45 @@ async function refreshDiscordTokens(prisma: PrismaClient) {
where: { where: {
expiresAt: { expiresAt: {
lte: new Date(Date.now() + refreshWindow) lte: new Date(Date.now() + refreshWindow)
} },
invalid: false
} }
}); });
for (const discordAuth of discordAuths) { for (const discordAuth of discordAuths) {
const req = await fetch("https://discord.com/api/oauth2/token", { const data = await DiscordAuthProvider.refreshToken(
method: "POST", discordAuth.refreshToken
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 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({ await prisma.discordAuth.update({
where: { where: {
id: discordAuth.id id: discordAuth.id
}, },
data: { data: {
accessToken: res.access_token, invalid: true
refreshToken: res.refresh_token, }
expiresAt: new Date(Date.now() + res.expires_in * 1000) });
} } 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)
}
});
}
} }
} }

View File

@ -87,3 +87,21 @@ export interface PasswordUpdateFormValues {
newPassword: string; newPassword: string;
confirmPassword: string; confirmPassword: string;
} }
// Types specific to the server, because sometimes we omit fields (like confirmPassword)
export const registerServerSchema: Yup.Schema<RegisterServerFormValues> =
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;
}