forked from NotNet/gluestick
Merge branch 'main' into tic80
This commit is contained in:
commit
e542e3fb4a
|
@ -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;
|
|
@ -34,13 +34,15 @@ model DiscordAuth {
|
|||
accessToken String
|
||||
refreshToken String
|
||||
expiresAt DateTime
|
||||
invalid Boolean
|
||||
}
|
||||
|
||||
model GitHubAuth {
|
||||
id Int @id
|
||||
id String @id
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId Int @unique
|
||||
|
||||
accessToken String
|
||||
invalid Boolean
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getUser } from "@/auth";
|
||||
import { getUser } from "@/auth/auth";
|
||||
import { getUserInfo, setPassword, validateUser } from "@/ldap";
|
||||
import { getLogger } from "@/logger";
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { authTicketLogin } from "@/auth/auth";
|
||||
import * as ldap from "@/ldap";
|
||||
import { createAuthTicket } from "@/auth";
|
||||
import { loginSchema } from "@/schemas";
|
||||
|
||||
type RequestBody = {
|
||||
username: string;
|
||||
|
@ -7,22 +8,9 @@ type RequestBody = {
|
|||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { username, password } = (await request.json()) as RequestBody;
|
||||
|
||||
if (
|
||||
username == undefined ||
|
||||
typeof username !== "string" ||
|
||||
password == undefined ||
|
||||
typeof password !== "string"
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: "invalidBody"
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const { username, password } = await loginSchema.validate(
|
||||
await request.json()
|
||||
);
|
||||
|
||||
const valid = await ldap.validateUser(username, password);
|
||||
if (!valid) {
|
||||
|
@ -35,7 +23,7 @@ export async function POST(request: Request) {
|
|||
);
|
||||
}
|
||||
|
||||
const ticket = await createAuthTicket(username);
|
||||
const [_, ticket] = await authTicketLogin(username);
|
||||
// not confident if we can set-cookie and I cba to try
|
||||
return new Response(JSON.stringify({ ok: true, ticket }));
|
||||
return new Response(JSON.stringify({ ok: true, ticket: ticket.ticket }));
|
||||
}
|
||||
|
|
|
@ -1,104 +1,27 @@
|
|||
import * as ldap from "@/ldap";
|
||||
import prisma from "@/prisma";
|
||||
import { getUser } from "@/auth";
|
||||
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
|
||||
import { getGitHubAvatar } from "@/app/oauth/github/oauth";
|
||||
import { getUser } from "@/auth/auth";
|
||||
import { getLogger } from "@/logger";
|
||||
import { registerServerSchema } from "@/schemas";
|
||||
|
||||
type RequestBody = {
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
avatarBase64: string | null;
|
||||
};
|
||||
const logger = getLogger("/api/register");
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const logger = getLogger("/api/register");
|
||||
|
||||
const user = await getUser();
|
||||
if (user == null) return new Response(null, { status: 401 });
|
||||
|
||||
if (user.username !== null) {
|
||||
if (!(await ldap.checkUserExists(user.username))) {
|
||||
logger.warn(
|
||||
{ username: user.username },
|
||||
"user doesn't exist in ldap anymore"
|
||||
);
|
||||
|
||||
user.username = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
data: {
|
||||
username: null
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info(`user ${user.username} tried to register twice`);
|
||||
// user already has an account, don't re-register
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const { username, displayName, email, password, avatarBase64 } =
|
||||
(await request.json()) as RequestBody;
|
||||
|
||||
// runtime type verification when :pleading:
|
||||
if (
|
||||
username == undefined ||
|
||||
typeof username !== "string" ||
|
||||
displayName == undefined ||
|
||||
typeof displayName !== "string" ||
|
||||
email == undefined ||
|
||||
typeof email !== "string" ||
|
||||
password == undefined ||
|
||||
typeof password !== "string"
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: "invalidBody"
|
||||
}),
|
||||
{ status: 400 }
|
||||
// user already has an account, don't re-register
|
||||
if (user.username != null) {
|
||||
logger.info(
|
||||
{ username: user.username, id: user.id },
|
||||
`user tried to register twice`
|
||||
);
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
|
||||
if (username.length < 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: "usernameShort"
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 12) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: "passwordShort"
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let avatarBuf: Buffer | null | undefined;
|
||||
|
||||
if (avatarBase64 !== null && typeof avatarBase64 === "string") {
|
||||
avatarBuf = Buffer.from(avatarBase64, "base64");
|
||||
if (avatarBuf.length > 1_000_000) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: "avatarBig"
|
||||
}),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
const { username, displayName, email, password, avatar } =
|
||||
await registerServerSchema.validate(await request.json());
|
||||
let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null;
|
||||
|
||||
const users = await ldap.getUsers();
|
||||
for (const user of users) {
|
||||
|
|
|
@ -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 });
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { getUser } from "@/auth";
|
||||
import { getUser } from "@/auth/auth";
|
||||
import { getUserInfo, updateUser } from "@/ldap";
|
||||
import { getLogger } from "@/logger";
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import styles from "@/app/page.module.css";
|
||||
import React from "react";
|
||||
import LoginForm from "./LoginForm";
|
||||
import { AuthState, getAuthState } from "@/auth";
|
||||
import { AuthState, getAuthState } from "@/auth/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page() {
|
||||
|
|
|
@ -24,3 +24,49 @@
|
|||
cursor: pointer;
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"use client";
|
||||
|
||||
import { UserInfo } from "@/ldap";
|
||||
import React, { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
|
||||
import React from "react";
|
||||
import styles from "./AboutMe.module.css";
|
||||
import AvatarChanger from "@/components/AvatarChanger";
|
||||
import Input from "@/components/Input";
|
||||
|
@ -15,6 +15,8 @@ import {
|
|||
} from "@/schemas";
|
||||
import PrettyForm from "@/components/PrettyForm";
|
||||
import Toast from "@/components/Toast";
|
||||
import { AuthProviderState } from "@/auth/AuthProvider";
|
||||
import { exec } from "child_process";
|
||||
|
||||
type UpdateResponse = {
|
||||
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 [madeProfileChanges, setMadeChanges] = React.useState(false);
|
||||
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
|
||||
|
@ -229,9 +288,10 @@ export default function AboutMe({ info }: { info: UserInfo }) {
|
|||
|
||||
<hr className={styles.divider} />
|
||||
<h2 className={styles.header}>Connections</h2>
|
||||
<div>
|
||||
<p>discord: {info.discordId}</p>
|
||||
<p>github: {info.githubId}</p>
|
||||
<div className={styles.authProviderList}>
|
||||
{providers.map((provider) => (
|
||||
<AuthProviderEntry provider={provider} key={provider.name} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<hr className={styles.divider} />
|
||||
|
|
|
@ -1,7 +1,21 @@
|
|||
import { getUser } from "@/auth";
|
||||
import { getUser } from "@/auth/auth";
|
||||
import { getUserInfo } from "@/ldap";
|
||||
import AboutMe from "./AboutMe";
|
||||
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() {
|
||||
const user = await getUser();
|
||||
|
@ -10,5 +24,13 @@ export default async function Page() {
|
|||
const info = await getUserInfo(user);
|
||||
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} />;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { discordRedirectUri } from "../oauth";
|
||||
import { DiscordAuthProvider } from "@/auth/discord";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
@ -10,7 +10,7 @@ export async function GET(request: Request) {
|
|||
params.set("client_id", process.env.DISCORD_CLIENT_ID);
|
||||
params.set("scope", "guilds identify email");
|
||||
params.set("state", state);
|
||||
params.set("redirect_uri", discordRedirectUri());
|
||||
params.set("redirect_uri", DiscordAuthProvider.redirectUri);
|
||||
params.set("prompt", "consent");
|
||||
|
||||
url += "?" + params.toString();
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,159 +1,49 @@
|
|||
import { URLSearchParams } from "url";
|
||||
import {
|
||||
discordRedirectUri,
|
||||
DiscordAccessTokenResponse,
|
||||
getDiscordGuilds,
|
||||
getDiscordUser,
|
||||
getDiscordAvatar
|
||||
} from "../oauth";
|
||||
import { cookies } from "next/dist/client/components/headers";
|
||||
import prisma from "@/prisma";
|
||||
import { v4 } from "uuid";
|
||||
import * as ldap from "@/ldap";
|
||||
import { getLogger } from "@/logger";
|
||||
import { AuthState, getAuthState, getUser } from "@/auth";
|
||||
import { DiscordAuthProvider } from "@/auth/discord";
|
||||
import {
|
||||
AuthState,
|
||||
authTicketOAuth,
|
||||
getAuthState,
|
||||
getCode,
|
||||
getUser
|
||||
} from "@/auth/auth";
|
||||
|
||||
const logger = getLogger("/oauth/discord/redirect");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const logger = getLogger("/oauth/discord/redirect");
|
||||
const code = await getCode(request);
|
||||
if (code instanceof Response) return code;
|
||||
|
||||
let url = new URL(request.url);
|
||||
let code = url.searchParams.get("code");
|
||||
let state = url.searchParams.get("state");
|
||||
const tokenBody = await DiscordAuthProvider.getToken(code);
|
||||
if (tokenBody == null) throw "baby";
|
||||
|
||||
if (code === null || state === null) {
|
||||
logger.info("request made with missing code/state");
|
||||
return new Response("missing code/state", { status: 400 });
|
||||
}
|
||||
const provider = new DiscordAuthProvider(tokenBody.access_token);
|
||||
const id = await provider.getId();
|
||||
const permitted = await provider.isPermitted();
|
||||
|
||||
const cookieStore = cookies();
|
||||
let cookieState = cookieStore.get("state");
|
||||
// prevent forgery
|
||||
if (cookieState?.value !== state) {
|
||||
logger.info(
|
||||
"request made with invalid state - someone attempting forgery?"
|
||||
);
|
||||
return new Response("state is invalid", { status: 400 });
|
||||
}
|
||||
|
||||
let form = new URLSearchParams();
|
||||
form.append("client_id", process.env.DISCORD_CLIENT_ID);
|
||||
form.append("client_secret", process.env.DISCORD_CLIENT_SECRET);
|
||||
form.append("grant_type", "authorization_code");
|
||||
form.append("code", code);
|
||||
form.append("redirect_uri", discordRedirectUri());
|
||||
|
||||
let tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: form.toString()
|
||||
});
|
||||
if (!tokenResponse.ok) {
|
||||
logger.error("baby");
|
||||
throw "baby";
|
||||
}
|
||||
|
||||
let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json();
|
||||
|
||||
const discordUser = await getDiscordUser(tokenBody.access_token);
|
||||
const guilds = await getDiscordGuilds(tokenBody.access_token);
|
||||
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
|
||||
|
||||
let allowed = false;
|
||||
for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true;
|
||||
if (!allowed) {
|
||||
logger.info({ id: discordUser.id }, "user tried to sign up");
|
||||
if (!permitted) {
|
||||
logger.info({ id }, "user tried to sign up");
|
||||
return new Response("not permitted to register account", { status: 403 });
|
||||
}
|
||||
|
||||
let userId = null;
|
||||
// If someone clicked register on the front page with an existing account,
|
||||
// wire it to their user via the auth ticket
|
||||
let gluestickId = null;
|
||||
const authState = await getAuthState();
|
||||
if (authState === AuthState.LoggedIn) {
|
||||
const currentUser = await getUser();
|
||||
userId = currentUser?.id;
|
||||
gluestickId = currentUser!.id;
|
||||
}
|
||||
|
||||
// - create the discord auth data in prisma, which will make the user if it doesn't exist
|
||||
// - get the user from the discord auth data
|
||||
// - either create a new auth ticket or invalidate the old one
|
||||
// - update the user to point to the new auth ticket
|
||||
const userId = await DiscordAuthProvider.update(
|
||||
id,
|
||||
tokenBody.access_token,
|
||||
tokenBody.refresh_token,
|
||||
new Date(Date.now() + tokenBody.expires_in * 1000),
|
||||
gluestickId ?? undefined
|
||||
);
|
||||
|
||||
const discordAuth = await prisma.discordAuth.upsert({
|
||||
where: {
|
||||
id: discordUser.id
|
||||
},
|
||||
create: {
|
||||
id: discordUser.id,
|
||||
accessToken: tokenBody.access_token,
|
||||
refreshToken: tokenBody.refresh_token,
|
||||
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000),
|
||||
user:
|
||||
userId != null
|
||||
? { connect: { id: userId } }
|
||||
: { create: { username: null } }
|
||||
},
|
||||
update: {
|
||||
accessToken: tokenBody.access_token,
|
||||
refreshToken: tokenBody.refresh_token,
|
||||
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000)
|
||||
}
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: discordAuth.userId
|
||||
}
|
||||
});
|
||||
|
||||
// check if user got deleted from ldap, same as /api/register
|
||||
if (
|
||||
user !== null &&
|
||||
user.username !== null &&
|
||||
!(await ldap.checkUserExists(user.username))
|
||||
) {
|
||||
logger.warn(
|
||||
{ username: user.username },
|
||||
"user doesn't exist in ldap anymore"
|
||||
);
|
||||
user.username = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
data: {
|
||||
username: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const authTicket = await prisma.authTicket.upsert({
|
||||
where: {
|
||||
userId: user!.id
|
||||
},
|
||||
create: {
|
||||
userId: user!.id,
|
||||
ticket: v4(),
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
},
|
||||
update: {
|
||||
ticket: v4(),
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user!.id
|
||||
},
|
||||
data: {
|
||||
authTicket: {
|
||||
connect: {
|
||||
id: authTicket.id
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const [user, authTicket] = await authTicketOAuth(userId);
|
||||
|
||||
if (user?.username !== null) {
|
||||
return new Response(null, {
|
||||
|
@ -165,11 +55,13 @@ export async function GET(request: Request) {
|
|||
});
|
||||
}
|
||||
|
||||
const avatarUrl = await getDiscordAvatar(tokenBody.access_token);
|
||||
const username = await provider.getDisplayName();
|
||||
const email = await provider.getEmail();
|
||||
const avatarUrl = await provider.getAvatar();
|
||||
|
||||
const query = new URLSearchParams();
|
||||
query.append("username", discordUser.username);
|
||||
query.append("email", discordUser.email ?? "");
|
||||
query.append("username", username);
|
||||
query.append("email", email ?? "");
|
||||
query.append("avatar", avatarUrl ?? "");
|
||||
|
||||
return new Response(null, {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { GitHubAuthProvider } from "@/auth/github";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
@ -8,7 +9,7 @@ export async function GET(request: Request) {
|
|||
params.set("client_id", process.env.GITHUB_CLIENT_ID);
|
||||
params.set("scope", "user");
|
||||
params.set("state", state);
|
||||
params.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`);
|
||||
params.set("redirect_uri", GitHubAuthProvider.redirectUri);
|
||||
|
||||
url += `?${params.toString()}`;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -1,164 +1,71 @@
|
|||
import { getLogger } from "@/logger";
|
||||
import { cookies } from "next/dist/client/components/headers";
|
||||
import { GitHubAuthProvider } from "@/auth/github";
|
||||
import {
|
||||
checkInOrg,
|
||||
getGitHubAvatar,
|
||||
getGitHubUser,
|
||||
GitHubAccessTokenResponse
|
||||
} from "../oauth";
|
||||
import prisma from "@/prisma";
|
||||
import * as ldap from "@/ldap";
|
||||
import { v4 } from "uuid";
|
||||
import { AuthState, getAuthState, getUser } from "@/auth";
|
||||
AuthState,
|
||||
authTicketOAuth,
|
||||
getAuthState,
|
||||
getCode,
|
||||
getUser
|
||||
} from "@/auth/auth";
|
||||
|
||||
const logger = getLogger("/oauth/github/redirect");
|
||||
|
||||
export async function GET(request: Request) {
|
||||
let url = new URL(request.url);
|
||||
let code = url.searchParams.get("code");
|
||||
let state = url.searchParams.get("state");
|
||||
const code = await getCode(request);
|
||||
if (code instanceof Response) return code;
|
||||
|
||||
if (code === null || state === null) {
|
||||
logger.info("request made with missing code/state");
|
||||
return new Response("missing code/state", { status: 400 });
|
||||
}
|
||||
const tokenBody = await GitHubAuthProvider.getToken(code);
|
||||
if (tokenBody == null) throw "baby";
|
||||
|
||||
const cookieStore = cookies();
|
||||
let cookieState = cookieStore.get("state");
|
||||
// prevent forgery
|
||||
if (cookieState?.value !== state) {
|
||||
logger.info(
|
||||
"request made with invalid state - someone attempting forgery?"
|
||||
);
|
||||
return new Response("state is invalid", { status: 400 });
|
||||
}
|
||||
const provider = new GitHubAuthProvider(tokenBody.access_token);
|
||||
const id = await provider.getId();
|
||||
const permitted = await provider.isPermitted();
|
||||
|
||||
let query = new URLSearchParams();
|
||||
query.set("client_id", process.env.GITHUB_CLIENT_ID);
|
||||
query.set("client_secret", process.env.GITHUB_CLIENT_SECRET);
|
||||
query.set("code", code);
|
||||
query.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`);
|
||||
|
||||
let tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`;
|
||||
let tokenResponse = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
logger.error("baby");
|
||||
throw "baby";
|
||||
}
|
||||
|
||||
let resp: GitHubAccessTokenResponse = await tokenResponse.json();
|
||||
let accessToken = resp.access_token;
|
||||
const githubUser = await getGitHubUser(accessToken);
|
||||
const inOrg = await checkInOrg(githubUser.login);
|
||||
|
||||
if (!inOrg) {
|
||||
logger.info({ id: githubUser.login }, "user tried to sign up");
|
||||
if (!permitted) {
|
||||
logger.info({ id }, "user tried to sign up");
|
||||
return new Response("not permitted to register account", { status: 403 });
|
||||
}
|
||||
|
||||
let userId = null;
|
||||
// If someone clicked register on the front page with an existing account,
|
||||
// wire it to their user via the auth ticket
|
||||
let gluestickId = null;
|
||||
const authState = await getAuthState();
|
||||
if (authState === AuthState.LoggedIn) {
|
||||
const currentUser = await getUser();
|
||||
userId = currentUser?.id;
|
||||
gluestickId = currentUser!.id;
|
||||
}
|
||||
|
||||
const githubAuth = await prisma.gitHubAuth.upsert({
|
||||
where: { id: githubUser.id },
|
||||
create: {
|
||||
id: githubUser.id,
|
||||
accessToken,
|
||||
user:
|
||||
userId != null
|
||||
? { connect: { id: userId } }
|
||||
: { create: { username: null } }
|
||||
},
|
||||
update: { accessToken }
|
||||
});
|
||||
const userId = await GitHubAuthProvider.update(
|
||||
id,
|
||||
tokenBody.access_token,
|
||||
gluestickId ?? undefined
|
||||
);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: githubAuth.userId
|
||||
}
|
||||
});
|
||||
|
||||
// check if user got deleted from ldap, same as /api/register
|
||||
if (
|
||||
user !== null &&
|
||||
user.username !== null &&
|
||||
!(await ldap.checkUserExists(user.username))
|
||||
) {
|
||||
logger.warn(
|
||||
{ username: user.username },
|
||||
"user doesn't exist in ldap anymore"
|
||||
);
|
||||
user.username = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id
|
||||
},
|
||||
data: {
|
||||
username: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const authTicket = await prisma.authTicket.upsert({
|
||||
where: {
|
||||
userId: user!.id
|
||||
},
|
||||
create: {
|
||||
userId: user!.id,
|
||||
ticket: v4(),
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
},
|
||||
update: {
|
||||
ticket: v4(),
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user!.id
|
||||
},
|
||||
data: {
|
||||
authTicket: {
|
||||
connect: {
|
||||
id: authTicket.id
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (user?.username !== null) {
|
||||
if (gluestickId != null) {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
|
||||
Location: "/me"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const avatarUrl = await getGitHubAvatar(accessToken);
|
||||
const [user, authTicket] = await authTicketOAuth(userId);
|
||||
|
||||
const query2 = new URLSearchParams();
|
||||
query2.append("username", githubUser.login);
|
||||
query2.append("email", githubUser.email);
|
||||
query2.append("avatar", avatarUrl);
|
||||
const username = await provider.getDisplayName();
|
||||
const email = await provider.getEmail();
|
||||
const avatarUrl = await provider.getAvatar();
|
||||
|
||||
const query = new URLSearchParams();
|
||||
query.append("username", username);
|
||||
query.append("email", email ?? "");
|
||||
query.append("avatar", avatarUrl ?? "");
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
|
||||
Location: "/register?" + query2.toString()
|
||||
Location: "/register?" + query.toString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,6 +12,14 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bail {
|
||||
color: var(--fg-darker);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -10,6 +10,7 @@ import Input from "@/components/Input";
|
|||
import PrettyForm from "@/components/PrettyForm";
|
||||
import HugeSubmit from "@/components/HugeSubmit";
|
||||
import AvatarChanger from "@/components/AvatarChanger";
|
||||
import { ValidAuthProvider } from "@/auth/AuthProvider";
|
||||
|
||||
type RegisterResponse = {
|
||||
ok: boolean;
|
||||
|
@ -19,11 +20,13 @@ type RegisterResponse = {
|
|||
export default function RegisterForm({
|
||||
initialDisplayName,
|
||||
initialEmail,
|
||||
initialAvatarBase64
|
||||
initialAvatarBase64,
|
||||
avatarSource
|
||||
}: {
|
||||
initialDisplayName?: string;
|
||||
initialEmail?: string;
|
||||
initialAvatarBase64?: string;
|
||||
avatarSource: ValidAuthProvider | null;
|
||||
}) {
|
||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
@ -53,7 +56,7 @@ export default function RegisterForm({
|
|||
displayName,
|
||||
email,
|
||||
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"
|
||||
placeholder="julian"
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your display name - this can be what you go by online, for example."
|
||||
type="text"
|
||||
|
@ -111,7 +113,6 @@ export default function RegisterForm({
|
|||
label="Display name"
|
||||
placeholder="NotNite"
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your email address. An inbox will be created on @n2.pm that forwards to this email."
|
||||
type="email"
|
||||
|
@ -119,7 +120,6 @@ export default function RegisterForm({
|
|||
label="Email"
|
||||
placeholder="hi@notnite.com"
|
||||
/>
|
||||
|
||||
<Input
|
||||
hint="Your password. To secure NotNet services, make this a strong and long password."
|
||||
type="password"
|
||||
|
@ -129,7 +129,6 @@ export default function RegisterForm({
|
|||
minLength={12}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
|
@ -137,10 +136,12 @@ export default function RegisterForm({
|
|||
placeholder="deeznuts47"
|
||||
minLength={12}
|
||||
/>
|
||||
|
||||
<Input
|
||||
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"
|
||||
name="avatar"
|
||||
|
@ -155,10 +156,21 @@ export default function RegisterForm({
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<HugeSubmit value="Join NotNet!" disabled={isSubmitting} />
|
||||
</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>
|
||||
)}
|
||||
</Formik>
|
||||
|
|
|
@ -3,13 +3,25 @@ import styles from "@/app/page.module.css";
|
|||
import RegisterForm from "./RegisterForm";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
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 {
|
||||
let github = url.hostname === "avatars.githubusercontent.com";
|
||||
let discord = url.hostname === "cdn.discordapp.com";
|
||||
|
||||
if (discord && !url.pathname.startsWith("/avatars")) return false;
|
||||
return github || discord;
|
||||
return avatarUrlSource(url) !== null;
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
|
@ -29,10 +41,13 @@ export default async function Page({
|
|||
}
|
||||
|
||||
let initialAvatarBase64 = undefined;
|
||||
let avatarSource = null;
|
||||
if (searchParams.avatar != null && 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>;
|
||||
}
|
||||
|
||||
|
@ -45,6 +60,7 @@ export default async function Page({
|
|||
try {
|
||||
const jpg = await ensureJpg(buffer);
|
||||
initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
|
||||
avatarSource = tempAvatarSource;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
@ -57,6 +73,7 @@ export default async function Page({
|
|||
initialDisplayName={searchParams.displayName}
|
||||
initialEmail={searchParams.email}
|
||||
initialAvatarBase64={initialAvatarBase64}
|
||||
avatarSource={avatarSource}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
|
112
src/auth.ts
112
src/auth.ts
|
@ -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;
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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];
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,8 @@
|
|||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { DiscordAccessTokenResponse } from "./app/oauth/discord/oauth";
|
||||
import { DiscordAuthProvider } from "./auth/discord";
|
||||
import { getLogger } from "./logger";
|
||||
|
||||
const logger = getLogger("prisma.ts");
|
||||
|
||||
async function refreshDiscordTokens(prisma: PrismaClient) {
|
||||
// refresh 6 hours before expiry
|
||||
|
@ -9,36 +12,45 @@ async function refreshDiscordTokens(prisma: PrismaClient) {
|
|||
where: {
|
||||
expiresAt: {
|
||||
lte: new Date(Date.now() + refreshWindow)
|
||||
}
|
||||
},
|
||||
invalid: false
|
||||
}
|
||||
});
|
||||
|
||||
for (const discordAuth of discordAuths) {
|
||||
const req = await fetch("https://discord.com/api/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: discordAuth.refreshToken
|
||||
}).toString()
|
||||
});
|
||||
const data = await DiscordAuthProvider.refreshToken(
|
||||
discordAuth.refreshToken
|
||||
);
|
||||
|
||||
const res: DiscordAccessTokenResponse = await req.json();
|
||||
if (data === null) {
|
||||
logger.warn(
|
||||
{
|
||||
user: discordAuth.userId,
|
||||
id: discordAuth.id
|
||||
},
|
||||
"failed to refresh discord token"
|
||||
);
|
||||
|
||||
await prisma.discordAuth.update({
|
||||
where: {
|
||||
id: discordAuth.id
|
||||
},
|
||||
data: {
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
expiresAt: new Date(Date.now() + res.expires_in * 1000)
|
||||
}
|
||||
});
|
||||
await prisma.discordAuth.update({
|
||||
where: {
|
||||
id: discordAuth.id
|
||||
},
|
||||
data: {
|
||||
invalid: true
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await prisma.discordAuth.update({
|
||||
where: {
|
||||
id: discordAuth.id
|
||||
},
|
||||
data: {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresAt: new Date(Date.now() + data.expires_in * 1000)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -87,3 +87,21 @@ export interface PasswordUpdateFormValues {
|
|||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
// Types specific to the server, because sometimes we omit fields (like confirmPassword)
|
||||
export const registerServerSchema: Yup.Schema<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;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue