diff --git a/next.config.js b/next.config.js index 6815fed..3695cbc 100644 --- a/next.config.js +++ b/next.config.js @@ -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, diff --git a/prisma/migrations/20221008211922_oauth_user/migration.sql b/prisma/migrations/20221008211922_oauth_user/migration.sql new file mode 100644 index 0000000..275497e --- /dev/null +++ b/prisma/migrations/20221008211922_oauth_user/migration.sql @@ -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; diff --git a/prisma/migrations/20221008214701_oauth_access_token/migration.sql b/prisma/migrations/20221008214701_oauth_access_token/migration.sql new file mode 100644 index 0000000..4226c2c --- /dev/null +++ b/prisma/migrations/20221008214701_oauth_access_token/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "oauthAccessToken" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f50b8e7..f55a9eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/components/icons/DiscordIcon.tsx b/src/components/icons/DiscordIcon.tsx new file mode 100644 index 0000000..821b163 --- /dev/null +++ b/src/components/icons/DiscordIcon.tsx @@ -0,0 +1,7 @@ +// https://discord.com/branding + +import Image from 'next/image'; + +export default function DiscordIcon({ ...props }) { + return ; +} \ No newline at end of file diff --git a/src/components/icons/GitHubIcon.tsx b/src/components/icons/GitHubIcon.tsx new file mode 100644 index 0000000..b13d43f --- /dev/null +++ b/src/components/icons/GitHubIcon.tsx @@ -0,0 +1,5 @@ +import { GitHub } from 'react-feather'; + +export default function GitHubIcon({ ...props }) { + return ; +} \ No newline at end of file diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts index 6834294..e14ab55 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -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; } \ No newline at end of file diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts index 3a2db64..9bd2ea4 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -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 = {}; diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts index 021231f..9c9cd66 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -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; diff --git a/src/lib/hooks/useLogin.ts b/src/lib/hooks/useLogin.ts index b218f7d..be7993e 100644 --- a/src/lib/hooks/useLogin.ts +++ b/src/lib/hooks/useLogin.ts @@ -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); diff --git a/src/lib/middleware/getServerSideProps.ts b/src/lib/middleware/getServerSideProps.ts index a897828..c6dceeb 100644 --- a/src/lib/middleware/getServerSideProps.ts +++ b/src/lib/middleware/getServerSideProps.ts @@ -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), }, }; }; \ No newline at end of file diff --git a/src/lib/middleware/withZipline.ts b/src/lib/middleware/withZipline.ts index d7abbf8..740350c 100644 --- a/src/lib/middleware/withZipline.ts +++ b/src/lib/middleware/withZipline.ts @@ -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; 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; diff --git a/src/lib/oauth/index.ts b/src/lib/oauth/index.ts new file mode 100644 index 0000000..55f8b5c --- /dev/null +++ b/src/lib/oauth/index.ts @@ -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(); + }, +}; \ No newline at end of file diff --git a/src/lib/util.ts b/src/lib/util.ts index 118c7df..9c260eb 100644 --- a/src/lib/util.ts +++ b/src/lib/util.ts @@ -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; } \ No newline at end of file diff --git a/src/pages/api/auth/oauth/discord.ts b/src/pages/api/auth/oauth/discord.ts new file mode 100644 index 0000000..eb6daa3 --- /dev/null +++ b/src/pages/api/auth/oauth/discord.ts @@ -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); \ No newline at end of file diff --git a/src/pages/api/auth/oauth/github.ts b/src/pages/api/auth/oauth/github.ts new file mode 100644 index 0000000..0a8cf14 --- /dev/null +++ b/src/pages/api/auth/oauth/github.ts @@ -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); \ No newline at end of file diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts index 3b290aa..a70768e 100644 --- a/src/pages/api/user/index.ts +++ b/src/pages/api/user/index.ts @@ -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); diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx index 6242ae2..586eb58 100644 --- a/src/pages/auth/login.tsx +++ b/src/pages/auth/login.tsx @@ -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() { + {oauth_registration && ( + + + + )} ); -} - -Login.title = 'Zipline - Login'; \ No newline at end of file +} \ No newline at end of file diff --git a/src/pages/auth/register.tsx b/src/pages/auth/register.tsx new file mode 100644 index 0000000..945ed56 --- /dev/null +++ b/src/pages/auth/register.tsx @@ -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 ( + <> +
+
+ {oauth_providers.map(({ url, name, Icon }, i) => ( + + + + ))} +
+
+ + ); +} \ No newline at end of file