rework authentication system
This commit is contained in:
parent
8d428d90a8
commit
91e54793ff
|
@ -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
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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 }));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,104 +1,27 @@
|
||||||
import * as ldap from "@/ldap";
|
import * as ldap from "@/ldap";
|
||||||
import prisma from "@/prisma";
|
import prisma from "@/prisma";
|
||||||
import { getUser } from "@/auth";
|
import { getUser } from "@/auth/auth";
|
||||||
import { getDiscordAvatar } from "@/app/oauth/discord/oauth";
|
|
||||||
import { getGitHubAvatar } from "@/app/oauth/github/oauth";
|
|
||||||
import { getLogger } from "@/logger";
|
import { getLogger } from "@/logger";
|
||||||
|
import { registerServerSchema } from "@/schemas";
|
||||||
|
|
||||||
type RequestBody = {
|
const logger = getLogger("/api/register");
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
avatarBase64: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const logger = getLogger("/api/register");
|
|
||||||
|
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
if (user == null) return new Response(null, { status: 401 });
|
if (user == null) return new Response(null, { status: 401 });
|
||||||
|
|
||||||
if (user.username !== null) {
|
// user already has an account, don't re-register
|
||||||
if (!(await ldap.checkUserExists(user.username))) {
|
if (user.username != null) {
|
||||||
logger.warn(
|
logger.info(
|
||||||
{ username: user.username },
|
{ username: user.username, id: user.id },
|
||||||
"user doesn't exist in ldap anymore"
|
`user tried to register twice`
|
||||||
);
|
|
||||||
|
|
||||||
user.username = null;
|
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
username: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.info(`user ${user.username} tried to register twice`);
|
|
||||||
// user already has an account, don't re-register
|
|
||||||
return new Response(null, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, displayName, email, password, avatarBase64 } =
|
|
||||||
(await request.json()) as RequestBody;
|
|
||||||
|
|
||||||
// runtime type verification when :pleading:
|
|
||||||
if (
|
|
||||||
username == undefined ||
|
|
||||||
typeof username !== "string" ||
|
|
||||||
displayName == undefined ||
|
|
||||||
typeof displayName !== "string" ||
|
|
||||||
email == undefined ||
|
|
||||||
typeof email !== "string" ||
|
|
||||||
password == undefined ||
|
|
||||||
typeof password !== "string"
|
|
||||||
) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
error: "invalidBody"
|
|
||||||
}),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
|
return new Response(null, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username.length < 1) {
|
const { username, displayName, email, password, avatar } =
|
||||||
return new Response(
|
await registerServerSchema.validate(await request.json());
|
||||||
JSON.stringify({
|
let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null;
|
||||||
ok: false,
|
|
||||||
error: "usernameShort"
|
|
||||||
}),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 12) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
error: "passwordShort"
|
|
||||||
}),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let avatarBuf: Buffer | null | undefined;
|
|
||||||
|
|
||||||
if (avatarBase64 !== null && typeof avatarBase64 === "string") {
|
|
||||||
avatarBuf = Buffer.from(avatarBase64, "base64");
|
|
||||||
if (avatarBuf.length > 1_000_000) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
error: "avatarBig"
|
|
||||||
}),
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await ldap.getUsers();
|
const users = await ldap.getUsers();
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { getUser } from "@/auth";
|
import { getUser } from "@/auth/auth";
|
||||||
import { getUserInfo } from "@/ldap";
|
import { getUserInfo } from "@/ldap";
|
||||||
import AboutMe from "./AboutMe";
|
import AboutMe from "./AboutMe";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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 { getLogger } from "@/logger";
|
||||||
import { AuthState, getAuthState, getUser } from "@/auth";
|
import { DiscordAuthProvider } from "@/auth/discord";
|
||||||
|
import {
|
||||||
|
AuthState,
|
||||||
|
authTicketOAuth,
|
||||||
|
getAuthState,
|
||||||
|
getCode,
|
||||||
|
getUser
|
||||||
|
} from "@/auth/auth";
|
||||||
|
|
||||||
|
const logger = getLogger("/oauth/discord/redirect");
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const logger = getLogger("/oauth/discord/redirect");
|
const code = await getCode(request);
|
||||||
|
if (code instanceof Response) return code;
|
||||||
|
|
||||||
let url = new URL(request.url);
|
const tokenBody = await DiscordAuthProvider.getToken(code);
|
||||||
let code = url.searchParams.get("code");
|
if (tokenBody == null) throw "baby";
|
||||||
let state = url.searchParams.get("state");
|
|
||||||
|
|
||||||
if (code === null || state === null) {
|
const provider = new DiscordAuthProvider(tokenBody.access_token);
|
||||||
logger.info("request made with missing code/state");
|
const id = await provider.getId();
|
||||||
return new Response("missing code/state", { status: 400 });
|
const permitted = await provider.isPermitted();
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = cookies();
|
if (!permitted) {
|
||||||
let cookieState = cookieStore.get("state");
|
logger.info({ id }, "user tried to sign up");
|
||||||
// prevent forgery
|
|
||||||
if (cookieState?.value !== state) {
|
|
||||||
logger.info(
|
|
||||||
"request made with invalid state - someone attempting forgery?"
|
|
||||||
);
|
|
||||||
return new Response("state is invalid", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let form = new URLSearchParams();
|
|
||||||
form.append("client_id", process.env.DISCORD_CLIENT_ID);
|
|
||||||
form.append("client_secret", process.env.DISCORD_CLIENT_SECRET);
|
|
||||||
form.append("grant_type", "authorization_code");
|
|
||||||
form.append("code", code);
|
|
||||||
form.append("redirect_uri", discordRedirectUri());
|
|
||||||
|
|
||||||
let tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded"
|
|
||||||
},
|
|
||||||
body: form.toString()
|
|
||||||
});
|
|
||||||
if (!tokenResponse.ok) {
|
|
||||||
logger.error("baby");
|
|
||||||
throw "baby";
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokenBody: DiscordAccessTokenResponse = await tokenResponse.json();
|
|
||||||
|
|
||||||
const discordUser = await getDiscordUser(tokenBody.access_token);
|
|
||||||
const guilds = await getDiscordGuilds(tokenBody.access_token);
|
|
||||||
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
|
|
||||||
|
|
||||||
let allowed = false;
|
|
||||||
for (const guild of allowedGuilds) if (guilds.includes(guild)) allowed = true;
|
|
||||||
if (!allowed) {
|
|
||||||
logger.info({ id: discordUser.id }, "user tried to sign up");
|
|
||||||
return new Response("not permitted to register account", { status: 403 });
|
return new Response("not permitted to register account", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let userId = null;
|
// If someone clicked register on the front page with an existing account,
|
||||||
|
// wire it to their user via the auth ticket
|
||||||
|
let gluestickId = null;
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
if (authState === AuthState.LoggedIn) {
|
if (authState === AuthState.LoggedIn) {
|
||||||
const currentUser = await getUser();
|
const currentUser = await getUser();
|
||||||
userId = currentUser?.id;
|
gluestickId = currentUser!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// - create the discord auth data in prisma, which will make the user if it doesn't exist
|
const userId = await DiscordAuthProvider.update(
|
||||||
// - get the user from the discord auth data
|
id,
|
||||||
// - either create a new auth ticket or invalidate the old one
|
tokenBody.access_token,
|
||||||
// - update the user to point to the new auth ticket
|
tokenBody.refresh_token,
|
||||||
|
new Date(Date.now() + tokenBody.expires_in * 1000),
|
||||||
|
gluestickId ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
const discordAuth = await prisma.discordAuth.upsert({
|
const [user, authTicket] = await authTicketOAuth(userId);
|
||||||
where: {
|
|
||||||
id: discordUser.id
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: discordUser.id,
|
|
||||||
accessToken: tokenBody.access_token,
|
|
||||||
refreshToken: tokenBody.refresh_token,
|
|
||||||
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000),
|
|
||||||
user:
|
|
||||||
userId != null
|
|
||||||
? { connect: { id: userId } }
|
|
||||||
: { create: { username: null } }
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
accessToken: tokenBody.access_token,
|
|
||||||
refreshToken: tokenBody.refresh_token,
|
|
||||||
expiresAt: new Date(Date.now() + tokenBody.expires_in * 1000)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
id: discordAuth.userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// check if user got deleted from ldap, same as /api/register
|
|
||||||
if (
|
|
||||||
user !== null &&
|
|
||||||
user.username !== null &&
|
|
||||||
!(await ldap.checkUserExists(user.username))
|
|
||||||
) {
|
|
||||||
logger.warn(
|
|
||||||
{ username: user.username },
|
|
||||||
"user doesn't exist in ldap anymore"
|
|
||||||
);
|
|
||||||
user.username = null;
|
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
username: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const authTicket = await prisma.authTicket.upsert({
|
|
||||||
where: {
|
|
||||||
userId: user!.id
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId: user!.id,
|
|
||||||
ticket: v4(),
|
|
||||||
expiresAt: new Date(Date.now() + 86400000)
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
ticket: v4(),
|
|
||||||
expiresAt: new Date(Date.now() + 86400000)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: user!.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
authTicket: {
|
|
||||||
connect: {
|
|
||||||
id: authTicket.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user?.username !== null) {
|
if (user?.username !== null) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
@ -165,11 +55,13 @@ export async function GET(request: Request) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarUrl = await getDiscordAvatar(tokenBody.access_token);
|
const username = await provider.getDisplayName();
|
||||||
|
const email = await provider.getEmail();
|
||||||
|
const avatarUrl = await provider.getAvatar();
|
||||||
|
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.append("username", discordUser.username);
|
query.append("username", username);
|
||||||
query.append("email", discordUser.email ?? "");
|
query.append("email", email ?? "");
|
||||||
query.append("avatar", avatarUrl ?? "");
|
query.append("avatar", avatarUrl ?? "");
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
|
|
@ -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()}`;
|
||||||
|
|
||||||
|
|
|
@ -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,141 +1,47 @@
|
||||||
import { getLogger } from "@/logger";
|
import { getLogger } from "@/logger";
|
||||||
import { cookies } from "next/dist/client/components/headers";
|
import { GitHubAuthProvider } from "@/auth/github";
|
||||||
import {
|
import {
|
||||||
checkInOrg,
|
AuthState,
|
||||||
getGitHubAvatar,
|
authTicketOAuth,
|
||||||
getGitHubUser,
|
getAuthState,
|
||||||
GitHubAccessTokenResponse
|
getCode,
|
||||||
} from "../oauth";
|
getUser
|
||||||
import prisma from "@/prisma";
|
} from "@/auth/auth";
|
||||||
import * as ldap from "@/ldap";
|
|
||||||
import { v4 } from "uuid";
|
|
||||||
import { AuthState, getAuthState, getUser } from "@/auth";
|
|
||||||
|
|
||||||
const logger = getLogger("/oauth/github/redirect");
|
const logger = getLogger("/oauth/github/redirect");
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
let url = new URL(request.url);
|
const code = await getCode(request);
|
||||||
let code = url.searchParams.get("code");
|
if (code instanceof Response) return code;
|
||||||
let state = url.searchParams.get("state");
|
|
||||||
|
|
||||||
if (code === null || state === null) {
|
const tokenBody = await GitHubAuthProvider.getToken(code);
|
||||||
logger.info("request made with missing code/state");
|
if (tokenBody == null) throw "baby";
|
||||||
return new Response("missing code/state", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookieStore = cookies();
|
const provider = new GitHubAuthProvider(tokenBody.access_token);
|
||||||
let cookieState = cookieStore.get("state");
|
const id = await provider.getId();
|
||||||
// prevent forgery
|
const permitted = await provider.isPermitted();
|
||||||
if (cookieState?.value !== state) {
|
|
||||||
logger.info(
|
|
||||||
"request made with invalid state - someone attempting forgery?"
|
|
||||||
);
|
|
||||||
return new Response("state is invalid", { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = new URLSearchParams();
|
if (!permitted) {
|
||||||
query.set("client_id", process.env.GITHUB_CLIENT_ID);
|
logger.info({ id }, "user tried to sign up");
|
||||||
query.set("client_secret", process.env.GITHUB_CLIENT_SECRET);
|
|
||||||
query.set("code", code);
|
|
||||||
query.set("redirect_uri", `${process.env.BASE_DOMAIN}oauth/github/redirect`);
|
|
||||||
|
|
||||||
let tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`;
|
|
||||||
let tokenResponse = await fetch(tokenUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
|
||||||
logger.error("baby");
|
|
||||||
throw "baby";
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp: GitHubAccessTokenResponse = await tokenResponse.json();
|
|
||||||
let accessToken = resp.access_token;
|
|
||||||
const githubUser = await getGitHubUser(accessToken);
|
|
||||||
const inOrg = await checkInOrg(githubUser.login);
|
|
||||||
|
|
||||||
if (!inOrg) {
|
|
||||||
logger.info({ id: githubUser.login }, "user tried to sign up");
|
|
||||||
return new Response("not permitted to register account", { status: 403 });
|
return new Response("not permitted to register account", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let userId = null;
|
// If someone clicked register on the front page with an existing account,
|
||||||
|
// wire it to their user via the auth ticket
|
||||||
|
let gluestickId = null;
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
if (authState === AuthState.LoggedIn) {
|
if (authState === AuthState.LoggedIn) {
|
||||||
const currentUser = await getUser();
|
const currentUser = await getUser();
|
||||||
userId = currentUser?.id;
|
gluestickId = currentUser!.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const githubAuth = await prisma.gitHubAuth.upsert({
|
const userId = await GitHubAuthProvider.update(
|
||||||
where: { id: githubUser.id },
|
id,
|
||||||
create: {
|
tokenBody.access_token,
|
||||||
id: githubUser.id,
|
gluestickId ?? undefined
|
||||||
accessToken,
|
);
|
||||||
user:
|
|
||||||
userId != null
|
|
||||||
? { connect: { id: userId } }
|
|
||||||
: { create: { username: null } }
|
|
||||||
},
|
|
||||||
update: { accessToken }
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const [user, authTicket] = await authTicketOAuth(userId);
|
||||||
where: {
|
|
||||||
id: githubAuth.userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// check if user got deleted from ldap, same as /api/register
|
|
||||||
if (
|
|
||||||
user !== null &&
|
|
||||||
user.username !== null &&
|
|
||||||
!(await ldap.checkUserExists(user.username))
|
|
||||||
) {
|
|
||||||
logger.warn(
|
|
||||||
{ username: user.username },
|
|
||||||
"user doesn't exist in ldap anymore"
|
|
||||||
);
|
|
||||||
user.username = null;
|
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: user.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
username: null
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const authTicket = await prisma.authTicket.upsert({
|
|
||||||
where: {
|
|
||||||
userId: user!.id
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
userId: user!.id,
|
|
||||||
ticket: v4(),
|
|
||||||
expiresAt: new Date(Date.now() + 86400000)
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
ticket: v4(),
|
|
||||||
expiresAt: new Date(Date.now() + 86400000)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: {
|
|
||||||
id: user!.id
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
authTicket: {
|
|
||||||
connect: {
|
|
||||||
id: authTicket.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (user?.username !== null) {
|
if (user?.username !== null) {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
|
@ -147,18 +53,20 @@ export async function GET(request: Request) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarUrl = await getGitHubAvatar(accessToken);
|
const username = await provider.getDisplayName();
|
||||||
|
const email = await provider.getEmail();
|
||||||
|
const avatarUrl = await provider.getAvatar();
|
||||||
|
|
||||||
const query2 = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query2.append("username", githubUser.login);
|
query.append("username", username);
|
||||||
query2.append("email", githubUser.email);
|
query.append("email", email ?? "");
|
||||||
query2.append("avatar", avatarUrl);
|
query.append("avatar", avatarUrl ?? "");
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
|
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
|
||||||
Location: "/register?" + query2.toString()
|
Location: "/register?" + query.toString()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,11 +19,13 @@ type RegisterResponse = {
|
||||||
export default function RegisterForm({
|
export default function RegisterForm({
|
||||||
initialDisplayName,
|
initialDisplayName,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
initialAvatarBase64
|
initialAvatarBase64,
|
||||||
|
avatarSource
|
||||||
}: {
|
}: {
|
||||||
initialDisplayName?: string;
|
initialDisplayName?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
initialAvatarBase64?: string;
|
initialAvatarBase64?: string;
|
||||||
|
avatarSource: "Discord" | "GitHub" | null;
|
||||||
}) {
|
}) {
|
||||||
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
const [globalError, setGlobalError] = React.useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -53,7 +55,7 @@ export default function RegisterForm({
|
||||||
displayName,
|
displayName,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
avatarBase64: avatar != null ? avatar.split(",")[1] : undefined
|
avatar: avatar != null ? avatar.split(",")[1] : undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -140,7 +142,10 @@ export default function RegisterForm({
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
hint={
|
hint={
|
||||||
"This image will automatically be used as your avatar with supported services - maximum 1 MB. "
|
(avatarSource != null && avatarSource !== "Discord"
|
||||||
|
? `We found your avatar from ${avatarSource}, but you can change it if you'd like.`
|
||||||
|
: "") +
|
||||||
|
" This will automatically be used as your avatar with supported services - maximum 1 MB. "
|
||||||
}
|
}
|
||||||
type="file"
|
type="file"
|
||||||
name="avatar"
|
name="avatar"
|
||||||
|
|
|
@ -4,12 +4,23 @@ import RegisterForm from "./RegisterForm";
|
||||||
import { redirect, useRouter } from "next/navigation";
|
import { redirect, useRouter } from "next/navigation";
|
||||||
import { ensureJpg } from "@/image";
|
import { ensureJpg } from "@/image";
|
||||||
|
|
||||||
function avatarUrlAllowed(url: URL): boolean {
|
function avatarUrlSource(url: URL): "Discord" | "GitHub" | null {
|
||||||
let github = url.hostname === "avatars.githubusercontent.com";
|
if (
|
||||||
let discord = url.hostname === "cdn.discordapp.com";
|
url.hostname === "cdn.discordapp.com" &&
|
||||||
|
url.pathname.startsWith("/avatars")
|
||||||
|
) {
|
||||||
|
return "Discord";
|
||||||
|
}
|
||||||
|
|
||||||
if (discord && !url.pathname.startsWith("/avatars")) return false;
|
if (url.hostname === "avatars.githubusercontent.com") {
|
||||||
return github || discord;
|
return "GitHub";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function avatarUrlAllowed(url: URL): boolean {
|
||||||
|
return avatarUrlSource(url) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({
|
export default async function Page({
|
||||||
|
@ -29,10 +40,13 @@ export default async function Page({
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialAvatarBase64 = undefined;
|
let initialAvatarBase64 = undefined;
|
||||||
|
let avatarSource = null;
|
||||||
if (searchParams.avatar != null && searchParams.avatar !== "") {
|
if (searchParams.avatar != null && searchParams.avatar !== "") {
|
||||||
const url = new URL(searchParams.avatar);
|
const url = new URL(searchParams.avatar);
|
||||||
|
let tempAvatarSource = avatarUrlSource(url);
|
||||||
|
|
||||||
if (!avatarUrlAllowed(url)) {
|
// prevent people from getting the server to fetch() arbitrary URLs
|
||||||
|
if (tempAvatarSource == null) {
|
||||||
return <p>fuck off</p>;
|
return <p>fuck off</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +59,7 @@ export default async function Page({
|
||||||
try {
|
try {
|
||||||
const jpg = await ensureJpg(buffer);
|
const jpg = await ensureJpg(buffer);
|
||||||
initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
|
initialAvatarBase64 = "data:image/jpeg;base64," + jpg;
|
||||||
|
avatarSource = tempAvatarSource;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
@ -57,6 +72,7 @@ export default async function Page({
|
||||||
initialDisplayName={searchParams.displayName}
|
initialDisplayName={searchParams.displayName}
|
||||||
initialEmail={searchParams.email}
|
initialEmail={searchParams.email}
|
||||||
initialAvatarBase64={initialAvatarBase64}
|
initialAvatarBase64={initialAvatarBase64}
|
||||||
|
avatarSource={avatarSource}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
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,28 @@
|
||||||
|
export abstract class AuthProvider {
|
||||||
|
protected readonly accessToken: string;
|
||||||
|
|
||||||
|
constructor(accessToken: string) {
|
||||||
|
this.accessToken = accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract isPermitted(): Promise<boolean>;
|
||||||
|
abstract getId(): Promise<string>;
|
||||||
|
|
||||||
|
// this difference only really matters for discordd
|
||||||
|
// display name:
|
||||||
|
// - discord: username
|
||||||
|
// - github: username
|
||||||
|
// username:
|
||||||
|
// - discord: username#discriminator
|
||||||
|
// - github: username
|
||||||
|
abstract getDisplayName(): Promise<string>;
|
||||||
|
abstract getUsername(): Promise<string>;
|
||||||
|
|
||||||
|
// these two aren't null for github
|
||||||
|
abstract getAvatar(): Promise<string | null>;
|
||||||
|
abstract getEmail(): Promise<string | null>;
|
||||||
|
|
||||||
|
static get redirectUri(): string {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,283 @@
|
||||||
|
import prisma from "@/prisma";
|
||||||
|
import { cookies } from "next/dist/client/components/headers";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import * as ldap from "../ldap";
|
||||||
|
import { getLogger } from "../logger";
|
||||||
|
import { AuthTicket, User } from "@prisma/client";
|
||||||
|
import { DiscordAuthProvider } from "./discord";
|
||||||
|
import { GitHubAuthProvider } from "./github";
|
||||||
|
import { AuthProvider } from "./AuthProvider";
|
||||||
|
|
||||||
|
const logger = getLogger("auth.ts");
|
||||||
|
|
||||||
|
export class GluestickUser {
|
||||||
|
private readonly dbTicket: AuthTicket;
|
||||||
|
private dbUser: User;
|
||||||
|
|
||||||
|
username: string | null;
|
||||||
|
|
||||||
|
constructor(ticket: AuthTicket, user: User) {
|
||||||
|
this.dbTicket = ticket;
|
||||||
|
this.dbUser = user;
|
||||||
|
|
||||||
|
this.username = user?.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): number {
|
||||||
|
return this.dbUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRegistering(): boolean {
|
||||||
|
return this.username == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get authTicket(): string {
|
||||||
|
return this.dbTicket.ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUsername(username?: string) {
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: this.dbUser.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dbUser = user;
|
||||||
|
this.username = username ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDiscord(): Promise<DiscordAuthProvider | null> {
|
||||||
|
const discord = await prisma.discordAuth.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: this.dbUser.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return discord === null
|
||||||
|
? null
|
||||||
|
: new DiscordAuthProvider(discord.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGitHub(): Promise<GitHubAuthProvider | null> {
|
||||||
|
const github = await prisma.gitHubAuth.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: this.dbUser.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return github === null ? null : new GitHubAuthProvider(github.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthProviders(): Promise<AuthProvider[]> {
|
||||||
|
const providers = [];
|
||||||
|
|
||||||
|
const discord = await this.getDiscord();
|
||||||
|
if (discord !== null) providers.push(discord);
|
||||||
|
|
||||||
|
const github = await this.getGitHub();
|
||||||
|
if (github !== null) providers.push(github);
|
||||||
|
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthTicket() {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const cookieTicket = cookieStore.get("ticket");
|
||||||
|
if (cookieTicket == null) return null;
|
||||||
|
|
||||||
|
const ticket = await prisma.authTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
ticket: cookieTicket.value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ticket ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(ticket?: AuthTicket) {
|
||||||
|
if (ticket == null) {
|
||||||
|
let newTicket = await getAuthTicket();
|
||||||
|
if (newTicket == null) return null;
|
||||||
|
ticket = newTicket;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: ticket.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
user !== null &&
|
||||||
|
user.username !== null &&
|
||||||
|
!(await ldap.checkUserExists(user.username))
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ username: user.username },
|
||||||
|
"user doesn't exist in ldap anymore"
|
||||||
|
);
|
||||||
|
|
||||||
|
user.username = null;
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
username: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user === null) return null;
|
||||||
|
|
||||||
|
return new GluestickUser(ticket, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AuthState {
|
||||||
|
LoggedOut, // no auth token
|
||||||
|
Registering, // has an auth token but no LDAP
|
||||||
|
LoggedIn // has an auth token and LDAP
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthState() {
|
||||||
|
const ticket = await getAuthTicket();
|
||||||
|
if (ticket == null) return AuthState.LoggedOut;
|
||||||
|
|
||||||
|
const user = await getUser(ticket);
|
||||||
|
if (user == null) return AuthState.Registering;
|
||||||
|
|
||||||
|
return AuthState.LoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCode(request: Request) {
|
||||||
|
let url = new URL(request.url);
|
||||||
|
let code = url.searchParams.get("code");
|
||||||
|
let state = url.searchParams.get("state");
|
||||||
|
|
||||||
|
if (code === null || state === null) {
|
||||||
|
logger.info("request made with missing code/state");
|
||||||
|
return new Response("missing code/state", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = cookies();
|
||||||
|
let cookieState = cookieStore.get("state");
|
||||||
|
// prevent forgery
|
||||||
|
if (cookieState?.value !== state) {
|
||||||
|
logger.info(
|
||||||
|
"request made with invalid state - someone attempting forgery?"
|
||||||
|
);
|
||||||
|
return new Response("state is invalid", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authTicketOAuth(id: number): Promise<[User, AuthTicket]> {
|
||||||
|
// Handle the case in which we already have a user
|
||||||
|
// (clicked on oauth button with existing ticket)
|
||||||
|
const user = (await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: id
|
||||||
|
}
|
||||||
|
}))!;
|
||||||
|
|
||||||
|
// check if user got deleted from ldap, same as /api/register
|
||||||
|
if (user.username !== null && !(await ldap.checkUserExists(user.username))) {
|
||||||
|
logger.warn(
|
||||||
|
{ username: user.username },
|
||||||
|
"user doesn't exist in ldap anymore"
|
||||||
|
);
|
||||||
|
user.username = null;
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
username: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTicket = await prisma.authTicket.upsert({
|
||||||
|
where: {
|
||||||
|
userId: user!.id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: user!.id,
|
||||||
|
ticket: v4(),
|
||||||
|
expiresAt: new Date(Date.now() + 86400000)
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
ticket: v4(),
|
||||||
|
expiresAt: new Date(Date.now() + 86400000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user!.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
authTicket: {
|
||||||
|
connect: {
|
||||||
|
id: authTicket.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [user, authTicket];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authTicketLogin(
|
||||||
|
username: string
|
||||||
|
): Promise<[User, AuthTicket]> {
|
||||||
|
let user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// maybe our DB got reset? let's just create a new user
|
||||||
|
if (user === null) {
|
||||||
|
logger.warn({ username }, "user exists in ldap, but not database");
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTicket = await prisma.authTicket.upsert({
|
||||||
|
where: {
|
||||||
|
userId: user!.id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: user!.id,
|
||||||
|
ticket: v4(),
|
||||||
|
expiresAt: new Date(Date.now() + 86400000)
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
ticket: v4(),
|
||||||
|
expiresAt: new Date(Date.now() + 86400000)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user!.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
authTicket: {
|
||||||
|
connect: {
|
||||||
|
id: authTicket.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [user, authTicket];
|
||||||
|
}
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { AuthProvider } from "./AuthProvider";
|
||||||
|
import prisma from "@/prisma";
|
||||||
|
|
||||||
|
export type DiscordAccessTokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
scope: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscordUserResponse = {
|
||||||
|
id: string;
|
||||||
|
avatar: string | null;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
discriminator: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DiscordGuildResponse = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class DiscordAuthProvider extends AuthProvider {
|
||||||
|
private async getMe(): Promise<DiscordUserResponse> {
|
||||||
|
const req = await fetch("https://discord.com/api/users/@me", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const res: DiscordUserResponse = await req.json();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isPermitted(): Promise<boolean> {
|
||||||
|
const req = await fetch("https://discord.com/api/users/@me/guilds", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const res: DiscordGuildResponse[] = await req.json();
|
||||||
|
const guilds = res.map((guild) => guild.id);
|
||||||
|
const allowedGuilds = process.env.DISCORD_ALLOWED_GUILDS?.split(",") ?? [];
|
||||||
|
|
||||||
|
let allowed = false;
|
||||||
|
for (const guild of allowedGuilds) {
|
||||||
|
if (guilds.includes(guild)) allowed = true;
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDisplayName(): Promise<string> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsername(): Promise<string> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.username + "#" + me.discriminator;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getId(): Promise<string> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvatar(): Promise<string | null> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.avatar !== null
|
||||||
|
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png`
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmail(): Promise<string | null> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get redirectUri(): string {
|
||||||
|
return `${process.env.BASE_DOMAIN}oauth/discord/redirect`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getToken(
|
||||||
|
code: string
|
||||||
|
): Promise<DiscordAccessTokenResponse | null> {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.append("client_id", process.env.DISCORD_CLIENT_ID);
|
||||||
|
form.append("client_secret", process.env.DISCORD_CLIENT_SECRET);
|
||||||
|
form.append("grant_type", "authorization_code");
|
||||||
|
form.append("code", code);
|
||||||
|
form.append("redirect_uri", this.redirectUri);
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
},
|
||||||
|
body: form.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) return null;
|
||||||
|
return await tokenResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refreshToken(
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<DiscordAccessTokenResponse | null> {
|
||||||
|
const req = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: process.env.DISCORD_CLIENT_ID,
|
||||||
|
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken
|
||||||
|
}).toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!req.ok) return null;
|
||||||
|
return await req.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(
|
||||||
|
id: string,
|
||||||
|
accessToken: string,
|
||||||
|
refreshToken: string,
|
||||||
|
expiresAt: Date,
|
||||||
|
userId?: number
|
||||||
|
): Promise<number> {
|
||||||
|
const a = await prisma.discordAuth.upsert({
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt,
|
||||||
|
user:
|
||||||
|
userId != null
|
||||||
|
? { connect: { id: userId } }
|
||||||
|
: { create: { username: null } },
|
||||||
|
invalid: false
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt,
|
||||||
|
invalid: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return a.userId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { AuthProvider } from "./AuthProvider";
|
||||||
|
import prisma from "@/prisma";
|
||||||
|
|
||||||
|
export type GitHubAccessTokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
scope: string;
|
||||||
|
token_type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GitHubUserResponse = {
|
||||||
|
login: string;
|
||||||
|
id: number;
|
||||||
|
avatar_url: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class GitHubAuthProvider extends AuthProvider {
|
||||||
|
private async getMe(): Promise<GitHubUserResponse> {
|
||||||
|
const req = await fetch("https://api.github.com/user", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.accessToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const res: GitHubUserResponse = await req.json();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isPermitted(): Promise<boolean> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
const req = await fetch(
|
||||||
|
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const res: GitHubUserResponse[] = await req.json();
|
||||||
|
return res.some((user) => user.login === me.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDisplayName(): Promise<string> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.login;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsername(): Promise<string> {
|
||||||
|
return this.getDisplayName();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getId(): Promise<string> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.id.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvatar(): Promise<string | null> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.avatar_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmail(): Promise<string | null> {
|
||||||
|
const me = await this.getMe();
|
||||||
|
return me.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get redirectUri(): string {
|
||||||
|
return `${process.env.BASE_DOMAIN}oauth/github/redirect`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getToken(
|
||||||
|
code: string
|
||||||
|
): Promise<GitHubAccessTokenResponse | null> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("client_id", process.env.GITHUB_CLIENT_ID);
|
||||||
|
query.set("client_secret", process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
query.set("code", code);
|
||||||
|
query.set("redirect_uri", this.redirectUri);
|
||||||
|
|
||||||
|
const tokenUrl = `https://github.com/login/oauth/access_token?${query.toString()}`;
|
||||||
|
const tokenResponse = await fetch(tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tokenResponse.ok) return null;
|
||||||
|
return await tokenResponse.json();
|
||||||
|
}
|
||||||
|
static async update(
|
||||||
|
id: string,
|
||||||
|
accessToken: string,
|
||||||
|
userId?: number
|
||||||
|
): Promise<number> {
|
||||||
|
const a = await prisma.gitHubAuth.upsert({
|
||||||
|
where: {
|
||||||
|
id
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
accessToken,
|
||||||
|
user:
|
||||||
|
userId != null
|
||||||
|
? { connect: { id: userId } }
|
||||||
|
: { create: { username: null } },
|
||||||
|
invalid: false
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
accessToken,
|
||||||
|
invalid: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return a.userId;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue