1
0
Fork 0

Merge branch 'main' into tic80

This commit is contained in:
Julian 2023-04-27 15:37:01 -04:00
commit e542e3fb4a
Signed by untrusted user: NotNite
GPG Key ID: BD91A5402CCEB08A
27 changed files with 1107 additions and 646 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: ticket.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 = {
username: string;
displayName: string;
email: string;
password: string;
avatarBase64: string | null;
};
export async function POST(request: Request) {
const logger = getLogger("/api/register"); const logger = getLogger("/api/register");
export async function POST(request: Request) {
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) {
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 // 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 }); return new Response(null, { status: 403 });
} }
}
const { username, displayName, email, password, avatarBase64 } = const { username, displayName, email, password, avatar } =
(await request.json()) as RequestBody; await registerServerSchema.validate(await request.json());
let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null;
// 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 }
);
}
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 users = await ldap.getUsers(); const users = await ldap.getUsers();
for (const user of users) { for (const user of users) {

View File

@ -0,0 +1,78 @@
import {
AuthState,
getAuthState,
getRegisteringUser,
getUser
} from "@/auth/auth";
import prisma from "@/prisma";
async function unlinkDiscord(id: string) {
await prisma.discordAuth.delete({
where: {
id
}
});
}
async function unlinkGitHub(id: string) {
await prisma.gitHubAuth.delete({
where: {
id
}
});
}
async function deleteUser(id: number) {
await prisma.authTicket.deleteMany({
where: {
userId: id
}
});
await prisma.user.delete({
where: {
id
}
});
}
export async function POST(request: Request) {
const authState = await getAuthState();
if (authState == AuthState.Registering) {
const registeringUser = (await getRegisteringUser())!;
if (registeringUser.discordAuth !== null)
await unlinkDiscord(registeringUser.discordAuth.id);
if (registeringUser.githubAuth !== null)
await unlinkGitHub(registeringUser.githubAuth.id);
await deleteUser(registeringUser.id);
return new Response(null, { status: 200 });
}
const user = await getUser();
if (user == null) return new Response(null, { status: 401 });
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
switch (provider) {
case "discord":
const discord = await user.getDiscord();
if (discord == null) return new Response(null, { status: 400 });
await unlinkDiscord(await discord.getId());
break;
case "github":
const github = await user.getGitHub();
if (github == null) return new Response(null, { status: 400 });
await unlinkGitHub(await github.getId());
break;
default:
return new Response(null, { status: 400 });
}
return new Response(null, { status: 200 });
}

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

@ -24,3 +24,49 @@
cursor: pointer; cursor: pointer;
padding: 0.5em 1em; padding: 0.5em 1em;
} }
/* stolen from prettyform */
.fancyInput {
background: var(--bg-dark);
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
}
.authProviderList {
display: grid;
grid-template-columns: max-content min-content;
/* padding */
grid-gap: 0.5rem;
}
.authProviderList p {
/* flex spam is fun */
display: flex;
align-items: center;
height: 100%;
}
/* the !importants here piss me off but it wouldn't accept the property otherwise */
.progress {
background: linear-gradient(
to right,
var(--fg-darker) 50%,
var(--bg-dark) 50%
) !important;
background-size: 200% 100% !important;
background-position: right bottom !important;
transition: all 0s linear !important;
border: 0;
border-radius: 0.15rem;
cursor: pointer;
padding: 0.5em 1em;
}
/* when clicked */
.progress:active {
transition: all 3s linear !important;
background-position: left bottom !important;
}

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";
@ -15,6 +15,8 @@ import {
} from "@/schemas"; } from "@/schemas";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import Toast from "@/components/Toast"; import Toast from "@/components/Toast";
import { AuthProviderState } from "@/auth/AuthProvider";
import { exec } from "child_process";
type UpdateResponse = { type UpdateResponse = {
ok: boolean; ok: boolean;
@ -34,7 +36,64 @@ async function fileAsBase64(f: File) {
}); });
} }
export default function AboutMe({ info }: { info: UserInfo }) { function AuthProviderButton({ provider }: { provider: AuthProviderState }) {
// bullshit hack
const holdTime = provider.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
const inputRef = React.useRef<HTMLInputElement>(null);
const execute = async () => {
const name = provider.name.toLowerCase();
if (!provider.connected) {
window.location.href = `/oauth/${name}/login`;
} else {
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
window.location.reload();
}
};
const mouseDown = () => {
interval.current = setTimeout(execute, holdTime);
};
const mouseUp = () => {
if (interval.current) clearTimeout(interval.current);
};
return (
<input
type="submit"
className={
styles.fancyInput + " " + (provider.connected ? styles.progress : "")
}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
value={provider.connected ? "Disconnect" : "Connect"}
ref={inputRef}
/>
);
}
function AuthProviderEntry({ provider }: { provider: AuthProviderState }) {
return (
<>
<p>
{provider.name}:{" "}
{provider.connected ? provider.username : "Not connected"}
</p>
<AuthProviderButton provider={provider} />
</>
);
}
export default function AboutMe({
info,
providers
}: {
info: UserInfo;
providers: AuthProviderState[];
}) {
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const [madeProfileChanges, setMadeChanges] = React.useState(false); const [madeProfileChanges, setMadeChanges] = React.useState(false);
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false); const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
@ -229,9 +288,10 @@ export default function AboutMe({ info }: { info: UserInfo }) {
<hr className={styles.divider} /> <hr className={styles.divider} />
<h2 className={styles.header}>Connections</h2> <h2 className={styles.header}>Connections</h2>
<div> <div className={styles.authProviderList}>
<p>discord: {info.discordId}</p> {providers.map((provider) => (
<p>github: {info.githubId}</p> <AuthProviderEntry provider={provider} key={provider.name} />
))}
</div> </div>
<hr className={styles.divider} /> <hr className={styles.divider} />

View File

@ -1,7 +1,21 @@
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";
import { DiscordAuthProvider } from "@/auth/discord";
import { GitHubAuthProvider } from "@/auth/github";
import { AuthProviderState } from "@/auth/AuthProvider";
// this sucks but i'm lazy
const discordFallback: AuthProviderState = {
name: "Discord",
connected: false
};
const githubFallback: AuthProviderState = {
name: "GitHub",
connected: false
};
export default async function Page() { export default async function Page() {
const user = await getUser(); const user = await getUser();
@ -10,5 +24,13 @@ export default async function Page() {
const info = await getUserInfo(user); const info = await getUserInfo(user);
if (info === null) redirect("/register"); if (info === null) redirect("/register");
return <AboutMe info={info} />; const discord = await user.getDiscord();
const discordState = (await discord?.getState()) ?? discordFallback;
const github = await user.getGitHub();
const githubState = (await github?.getState()) ?? githubFallback;
const providers = [discordState, githubState];
// not sure how to feel about passing it like this
return <AboutMe info={info} providers={providers} />;
} }

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";
export async function GET(request: Request) {
const logger = getLogger("/oauth/discord/redirect"); const logger = getLogger("/oauth/discord/redirect");
let url = new URL(request.url); export async function GET(request: Request) {
let code = url.searchParams.get("code"); const code = await getCode(request);
let state = url.searchParams.get("state"); if (code instanceof Response) return code;
if (code === null || state === null) { const tokenBody = await DiscordAuthProvider.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 DiscordAuthProvider(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 form = new URLSearchParams(); if (!permitted) {
form.append("client_id", process.env.DISCORD_CLIENT_ID); logger.info({ id }, "user tried to sign up");
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),
const discordAuth = await prisma.discordAuth.upsert({ gluestickId ?? undefined
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({ const [user, authTicket] = await authTicketOAuth(userId);
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,164 +1,71 @@
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({
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({ if (gluestickId != null) {
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) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
Location: "/me" Location: "/me"
} }
}); });
} }
const avatarUrl = await getGitHubAvatar(accessToken); const [user, authTicket] = await authTicketOAuth(userId);
const query2 = new URLSearchParams(); const username = await provider.getDisplayName();
query2.append("username", githubUser.login); const email = await provider.getEmail();
query2.append("email", githubUser.email); const avatarUrl = await provider.getAvatar();
query2.append("avatar", avatarUrl);
const query = new URLSearchParams();
query.append("username", username);
query.append("email", email ?? "");
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

@ -12,6 +12,14 @@
font-weight: 600; font-weight: 600;
} }
.bail {
color: var(--fg-darker);
display: flex;
justify-content: center;
cursor: pointer;
}
.buttonContainer { .buttonContainer {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@ -10,6 +10,7 @@ import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import HugeSubmit from "@/components/HugeSubmit"; import HugeSubmit from "@/components/HugeSubmit";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import { ValidAuthProvider } from "@/auth/AuthProvider";
type RegisterResponse = { type RegisterResponse = {
ok: boolean; ok: boolean;
@ -19,11 +20,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: ValidAuthProvider | 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 +56,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
}) })
}); });
@ -103,7 +106,6 @@ export default function RegisterForm({
label="Username" label="Username"
placeholder="julian" placeholder="julian"
/> />
<Input <Input
hint="Your display name - this can be what you go by online, for example." hint="Your display name - this can be what you go by online, for example."
type="text" type="text"
@ -111,7 +113,6 @@ export default function RegisterForm({
label="Display name" label="Display name"
placeholder="NotNite" placeholder="NotNite"
/> />
<Input <Input
hint="Your email address. An inbox will be created on @n2.pm that forwards to this email." hint="Your email address. An inbox will be created on @n2.pm that forwards to this email."
type="email" type="email"
@ -119,7 +120,6 @@ export default function RegisterForm({
label="Email" label="Email"
placeholder="hi@notnite.com" placeholder="hi@notnite.com"
/> />
<Input <Input
hint="Your password. To secure NotNet services, make this a strong and long password." hint="Your password. To secure NotNet services, make this a strong and long password."
type="password" type="password"
@ -129,7 +129,6 @@ export default function RegisterForm({
minLength={12} minLength={12}
autoComplete="new-password" autoComplete="new-password"
/> />
<Input <Input
type="password" type="password"
name="confirmPassword" name="confirmPassword"
@ -137,10 +136,12 @@ export default function RegisterForm({
placeholder="deeznuts47" placeholder="deeznuts47"
minLength={12} minLength={12}
/> />
<Input <Input
hint={ hint={
"This image will automatically be used as your avatar with supported services - maximum 1 MB. " (avatarSource != null
? `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"
@ -155,10 +156,21 @@ export default function RegisterForm({
/> />
)} )}
/> />
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<HugeSubmit value="Join NotNet!" disabled={isSubmitting} /> <HugeSubmit value="Join NotNet!" disabled={isSubmitting} />
</div> </div>
<a
className={styles.bail}
onClick={async () => {
await fetch("/api/unlink", { method: "POST" });
document.cookie =
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/";
}}
>
{`Didn't mean to sign up?`}
</a>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@ -3,13 +3,25 @@ import styles from "@/app/page.module.css";
import RegisterForm from "./RegisterForm"; import RegisterForm from "./RegisterForm";
import { redirect, useRouter } from "next/navigation"; import { redirect, useRouter } from "next/navigation";
import { ensureJpg } from "@/image"; import { ensureJpg } from "@/image";
import { ValidAuthProvider } from "@/auth/AuthProvider";
function avatarUrlSource(url: URL): ValidAuthProvider | null {
if (
url.hostname === "cdn.discordapp.com" &&
url.pathname.startsWith("/avatars")
) {
return "Discord";
}
if (url.hostname === "avatars.githubusercontent.com") {
return "GitHub";
}
return null;
}
function avatarUrlAllowed(url: URL): boolean { function avatarUrlAllowed(url: URL): boolean {
let github = url.hostname === "avatars.githubusercontent.com"; return avatarUrlSource(url) !== null;
let discord = url.hostname === "cdn.discordapp.com";
if (discord && !url.pathname.startsWith("/avatars")) return false;
return github || discord;
} }
export default async function Page({ export default async function Page({
@ -29,10 +41,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 +60,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 +73,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;
}

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

@ -0,0 +1,35 @@
export type ValidAuthProvider = "Discord" | "GitHub";
// Can't send the providers across the wire, do this instead
export type AuthProviderState = {
name: string;
} & ({ connected: false } | { connected: true; id: string; username: string });
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");
}
}

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

@ -0,0 +1,303 @@
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, DiscordAuth, 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 getRegisteringUser(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
},
include: {
discordAuth: true,
githubAuth: true
}
});
return user;
}
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 || user.isRegistering) 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];
}

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

@ -0,0 +1,170 @@
import { AuthProvider, AuthProviderState } 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;
}
async getState(): Promise<AuthProviderState> {
const username = await this.getUsername();
const id = await this.getId();
return {
name: "Discord",
connected: true,
id,
username
};
}
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;
}
}

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

@ -0,0 +1,141 @@
import { AuthProvider, AuthProviderState } 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;
}
async getState(): Promise<AuthProviderState> {
const username = await this.getUsername();
const id = await this.getId();
return {
name: "GitHub",
connected: true,
id,
username
};
}
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
}
});
await prisma.user.update({
where: {
id: a.userId
},
data: {
githubAuth: {
connect: {
id
}
}
}
});
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;
}