feat: oauth sign up

This commit is contained in:
diced 2022-10-08 17:58:56 -07:00
parent 0f641aa852
commit b0c3c6f45a
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
19 changed files with 397 additions and 48 deletions

View file

@ -17,6 +17,8 @@ module.exports = {
'getsharex.com',
// For flameshot icon, and maybe in the future other stuff from github
'raw.githubusercontent.com',
// Discord Icon
'assets-global.website-files.com',
],
},
poweredByHeader: false,

View file

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "oauth" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "oauthProvider" TEXT,
ALTER COLUMN "password" DROP NOT NULL;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "oauthAccessToken" TEXT;

View file

@ -8,21 +8,24 @@ generator client {
}
model User {
id Int @id @default(autoincrement())
username String
password String
avatar String?
token String
administrator Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimit DateTime?
domains String[]
images Image[]
urls Url[]
Invite Invite[]
id Int @id @default(autoincrement())
username String
password String?
oauth Boolean @default(false)
oauthProvider String?
oauthAccessToken String?
avatar String?
token String
administrator Boolean @default(false)
systemTheme String @default("system")
embedTitle String?
embedColor String @default("#2f3136")
embedSiteName String? @default("{image.file} • {user.name}")
ratelimit DateTime?
domains String[]
images Image[]
urls Url[]
Invite Invite[]
}
enum ImageFormat {

View file

@ -0,0 +1,7 @@
// https://discord.com/branding
import Image from 'next/image';
export default function DiscordIcon({ ...props }) {
return <Image src='https://assets-global.website-files.com/6257adef93867e50d84d30e2/62595384f934b806f37f4956_145dc557845548a36a82337912ca3ac5.svg' width={24} height={24} {...props} />;
}

View file

@ -0,0 +1,5 @@
import { GitHub } from 'react-feather';
export default function GitHubIcon({ ...props }) {
return <GitHub size={24} {...props} />;
}

View file

@ -99,6 +99,15 @@ export interface ConfigDiscordEmbed {
export interface ConfigFeatures {
invites: boolean;
oauth_registration: boolean;
}
export interface ConfigOAuth {
github_client_id?: string;
github_client_secret?: string;
discord_client_id?: string;
discord_client_secret?: string;
}
export interface Config {
@ -109,5 +118,6 @@ export interface Config {
datasource: ConfigDatasource;
website: ConfigWebsite;
discord: ConfigDiscord;
oauth: ConfigOAuth;
features: ConfigFeatures;
}

View file

@ -110,8 +110,15 @@ export default function readConfig() {
map('DISCORD_SHORTEN_EMBED_IMAGE', 'boolean', 'discord.shorten.embed.image'),
map('DISCORD_SHORTEN_EMBED_THUMBNAIL', 'boolean', 'discord.shorten.embed.thumbnail'),
map('DISCORD_SHORTEN_EMBED_TIMESTAMP', 'boolean', 'discord.shorten.embed.timestamp'),
map('OAUTH_GITHUB_CLIENT_ID', 'string', 'oauth.github_client_id'),
map('OAUTH_GITHUB_CLIENT_SECRET', 'string', 'oauth.github_client_secret'),
map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'),
map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'),
map('FEATURES_INVITES', 'boolean', 'features.invites'),
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
];
const config = {};

View file

@ -86,9 +86,18 @@ const validator = object({
upload: discord_content,
shorten: discord_content,
}).optional().nullable().default(null),
oauth: object({
github_client_id: string().nullable().default(null),
github_client_secret: string().nullable().default(null),
discord_client_id: string().nullable().default(null),
discord_client_secret: string().nullable().default(null),
}).optional().nullable().default(null),
features: object({
invites: boolean().default(true),
oauth_registration: boolean().default(false),
}).required(),
});
export default function validate(config): Config {
@ -125,6 +134,7 @@ export default function validate(config): Config {
}
}
console.log(validated);
return validated as unknown as Config;
} catch (e) {
if (process.env.ZIPLINE_DOCKER_BUILD) return null;

View file

@ -13,7 +13,11 @@ export default function login() {
setLoading(true);
const res = await useFetch('/api/user');
if (res.error) return router.push('/auth/login?url=' + router.route);
if (res.error) {
if (res.error === 'oauth token expired') return router.push(res.redirect_uri);
return router.push('/auth/login?url=' + router.route);
}
setUser(res);
setLoading(false);

View file

@ -1,13 +1,32 @@
import config from 'lib/config';
import { discord_auth, github_auth } from 'lib/oauth';
import { notNull } from 'lib/util';
import { GetServerSideProps } from 'next';
export const getServerSideProps: GetServerSideProps = async () => {
export const getServerSideProps: GetServerSideProps = async ctx => {
// this entire thing will also probably change before the stable release
const ghEnabled = notNull(config.oauth.github_client_id, config.oauth.github_client_secret);
const discEnabled = notNull(config.oauth.discord_client_id, config.oauth.discord_client_secret);
const oauth_providers = [];
if (ghEnabled) oauth_providers.push({
name: 'GitHub',
url: github_auth.oauth_url(config.oauth.github_client_id),
});
if (discEnabled) oauth_providers.push({
name: 'Discord',
url: discord_auth.oauth_url(config.oauth.discord_client_id, `${config.core.https ? 'https' : 'http'}://${ctx.req.headers.host}`),
});
return {
props: {
title: config.website.title,
external_links: JSON.stringify(config.website.external_links),
disable_media_preview: config.website.disable_media_preview,
invites: config.features.invites,
oauth_registration: config.features.oauth_registration,
oauth_providers: JSON.stringify(oauth_providers),
},
};
};

View file

@ -5,6 +5,7 @@ import { serialize } from 'cookie';
import { sign64, unsign64 } from 'lib/util';
import config from 'lib/config';
import prisma from 'lib/prisma';
import { User } from '@prisma/client';
export interface NextApiFile {
fieldname: string;
@ -16,18 +17,7 @@ export interface NextApiFile {
}
export type NextApiReq = NextApiRequest & {
user: () => Promise<{
username: string;
token: string;
embedTitle: string;
embedColor: string;
systemTheme: string;
administrator: boolean;
id: number;
password: string;
domains: string[];
avatar?: string;
} | null | void>;
user: () => Promise<User | null | void>;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
files?: NextApiFile[];
@ -100,23 +90,11 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
try {
const userId = req.getCookie('user');
if (!userId) return null;
const user = await prisma.user.findFirst({
where: {
id: Number(userId),
},
select: {
administrator: true,
embedColor: true,
embedTitle: true,
id: true,
password: true,
systemTheme: true,
token: true,
username: true,
domains: true,
avatar: true,
},
});
if (!user) return null;
@ -140,7 +118,7 @@ export const setCookie = (
value: unknown,
options: CookieSerializeOptions = {}
) => {
if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge * 1000);
options.maxAge /= 1000;

27
src/lib/oauth/index.ts Normal file
View file

@ -0,0 +1,27 @@
export const github_auth = {
oauth_url: (clientId: string) => `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=user`,
oauth_user: async (access_token: string) => {
const res = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${access_token}`,
},
});
if (!res.ok) return null;
return res.json();
},
};
export const discord_auth = {
oauth_url: (clientId: string, origin: string) => `https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(`${origin}/api/auth/oauth/discord`)}&response_type=code&scope=identify`,
oauth_user: async (access_token: string) => {
const res = await fetch('https://discord.com/api/users/@me', {
headers: {
'Authorization': `Bearer ${access_token}`,
},
});
if (!res.ok) return null;
return res.json();
},
};

View file

@ -144,4 +144,18 @@ export function createInvisURL(length: number, urlId: string) {
};
return retry();
}
export async function getBase64URLFromURL(url: string) {
const res = await fetch(url);
if (!res.ok) return null;
const buffer = await res.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
return `data:${res.headers.get('content-type')};base64,${base64}`;
}
export async function notNull(a: any, b: any) {
return a !== null && b !== null;
}

View file

@ -0,0 +1,88 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { createToken, getBase64URLFromURL, notNull } from 'lib/util';
import Logger from 'lib/logger';
import config from 'lib/config';
import { discord_auth } from 'lib/oauth';
async function handler(req: NextApiReq, res: NextApiRes) {
if (!config.features.oauth_registration) return res.forbid('oauth registration disabled');
if (!notNull(config.oauth.discord_client_id, config.oauth.discord_client_secret)) {
Logger.get('oauth').error('Discord OAuth is not configured');
return res.bad('Discord OAuth is not configured');
}
const { code } = req.query as { code: string };
if (!code) return res.bad('no code');
const resp = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: config.oauth.discord_client_id,
client_secret: config.oauth.discord_client_secret,
code,
grant_type: 'authorization_code',
redirect_uri: `${config.core.https ? 'https' : 'http'}://${req.headers.host}/api/auth/oauth/discord`,
scope: 'identify',
}),
});
if (!resp.ok) return res.error('invalid request');
const json = await resp.json();
if (!json.access_token) return res.error('no access_token in response');
const userJson = await discord_auth.oauth_user(json.access_token);
if (!userJson) return res.error('invalid user request');
const avatar = userJson.avatar ? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
const avatarBase64 = await getBase64URLFromURL(avatar);
const existing = await prisma.user.findFirst({
where: {
username: userJson.username,
},
});
if (existing && existing.oauth && existing.oauthProvider === 'discord') {
await prisma.user.update({
where: {
id: existing.id,
},
data: {
oauthAccessToken: json.access_token,
},
});
req.cleanCookie('user');
res.setCookie('user', existing.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(discord)`);
return res.redirect('/dashboard');
} else if (existing) {
return res.forbid('username is already taken');
}
const user = await prisma.user.create({
data: {
username: userJson.username,
token: createToken(),
oauth: true,
oauthProvider: 'discord',
oauthAccessToken: json.access_token,
avatar: avatarBase64,
},
});
Logger.get('user').info(`Created user ${user.username} via oauth(discord)`);
req.cleanCookie('user');
res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
Logger.get('user').info(`User ${user.username} (${user.id}) logged in via oauth(discord)`);
return res.redirect('/dashboard');
}
export default withZipline(handler);

View file

@ -0,0 +1,88 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
import { createToken, getBase64URLFromURL, notNull } from 'lib/util';
import Logger from 'lib/logger';
import config from 'lib/config';
import { github_auth } from 'lib/oauth';
async function handler(req: NextApiReq, res: NextApiRes) {
if (!config.features.oauth_registration) return res.forbid('oauth registration disabled');
if (!notNull(config.oauth.github_client_id, config.oauth.github_client_secret)) {
Logger.get('oauth').error('GitHub OAuth is not configured');
return res.bad('GitHub OAuth is not configured');
}
const { code } = req.query as { code: string };
if (!code) return res.bad('no code');
const resp = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
client_id: config.oauth.github_client_id,
client_secret: config.oauth.github_client_secret,
code,
}),
});
if (!resp.ok) return res.error('invalid request');
const json = await resp.json();
if (!json.access_token) return res.error('no access_token in response');
const userJson = await github_auth.oauth_user(json.access_token);
if (!userJson) return res.error('invalid user request');
const avatarBase64 = await getBase64URLFromURL(userJson.avatar_url);
const existing = await prisma.user.findFirst({
where: {
username: userJson.login,
},
});
if (existing && existing.oauth && existing.oauthProvider === 'github') {
await prisma.user.update({
where: {
id: existing.id,
},
data: {
oauthAccessToken: json.access_token,
},
});
req.cleanCookie('user');
res.setCookie('user', existing.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(github)`);
return res.redirect('/dashboard');
} else if (existing) {
return res.forbid('username is already taken');
}
const user = await prisma.user.create({
data: {
username: userJson.login,
token: createToken(),
oauth: true,
oauthProvider: 'github',
oauthAccessToken: json.access_token,
avatar: avatarBase64,
},
});
Logger.get('user').info(`Created user ${user.username} via oauth(github)`);
req.cleanCookie('user');
res.setCookie('user', user.id, { sameSite: true, expires: new Date(Date.now() + (6.048e+8 * 2)), path: '/' });
Logger.get('user').info(`User ${user.username} (${user.id}) logged in via oauth(github)`);
return res.redirect('/dashboard');
}
export default withZipline(handler);

View file

@ -2,11 +2,38 @@ import prisma from 'lib/prisma';
import { hashPassword } from 'lib/util';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import Logger from 'lib/logger';
import config from 'lib/config';
import { discord_auth, github_auth } from 'lib/oauth';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
if (!user) return res.forbid('not logged in');
if (user.oauth) {
// this will probably change before the stable release
if (user.oauthProvider === 'github') {
const resp = await github_auth.oauth_user(user.oauthAccessToken);
if (!resp) {
req.cleanCookie('user');
Logger.get('user').info(`User ${user.username} (${user.id}) logged out (oauth token expired)`);
return res.json({ error: 'oauth token expired', redirect_uri: github_auth.oauth_url(config.oauth.github_client_id) });
}
} else if (user.oauthProvider === 'discord') {
const resp = await fetch('https://discord.com/api/users/@me', {
headers: {
'Authorization': `Bearer ${user.oauthAccessToken}`,
},
});
if (!resp.ok) {
req.cleanCookie('user');
Logger.get('user').info(`User ${user.username} (${user.id}) logged out (oauth token expired)`);
return res.json({ error: 'oauth token expired', redirect_uri: discord_auth.oauth_url(config.oauth.discord_client_id, `${config.core.https ? 'https' : 'http'}://${req.headers.host}`) });
}
}
}
if (req.method === 'PATCH') {
if (req.body.password) {
const hashed = await hashPassword(req.body.password);

View file

@ -1,10 +1,12 @@
import { Button, Center, TextInput, Title, PasswordInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import Link from 'next/link';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export { getServerSideProps } from 'middleware/getServerSideProps';
export default function Login() {
export default function Login({ oauth_registration }) {
const router = useRouter();
const form = useForm({
@ -54,10 +56,13 @@ export default function Login() {
<Button size='lg' type='submit' fullWidth mt={12}>Login</Button>
</form>
{oauth_registration && (
<Link href='/auth/register' passHref>
<Button size='lg' fullWidth mt={12} component='a'>Register</Button>
</Link>
)}
</div>
</Center>
</>
);
}
Login.title = 'Zipline - Login';
}

View file

@ -0,0 +1,49 @@
import { Button, Center, TextInput, Title, PasswordInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import Link from 'next/link';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import GitHubIcon from 'components/icons/GitHubIcon';
import DiscordIcon from 'components/icons/DiscordIcon';
export { getServerSideProps } from 'middleware/getServerSideProps';
export default function Login({ oauth_registration, oauth_providers: unparsed }) {
const oauth_providers = JSON.parse(unparsed);
const icons = {
GitHub: GitHubIcon,
Discord: DiscordIcon,
};
for (const provider of oauth_providers) {
provider.Icon = icons[provider.name];
}
const router = useRouter();
if (!oauth_registration) {
router.push('/auth/login');
return null;
};
useEffect(() => {
(async () => {
const a = await fetch('/api/user');
if (a.ok) await router.push('/dashboard');
})();
}, []);
return (
<>
<Center sx={{ height: '100vh' }}>
<div>
{oauth_providers.map(({ url, name, Icon }, i) => (
<Link key={i} href={url} passHref>
<Button size='lg' fullWidth mt={12} leftIcon={<Icon />} component='a'>Sign in with {name}</Button>
</Link>
))}
</div>
</Center>
</>
);
}