From 561849ae5bae2b8e06a36c9f90407eb274ca89d4 Mon Sep 17 00:00:00 2001 From: diced Date: Sat, 29 Oct 2022 20:02:54 -0700 Subject: [PATCH] feat: ability to link existing accounts to oauth --- src/components/pages/Manage/index.tsx | 61 +++++++++++++++++++++++- src/lib/middleware/getServerSideProps.ts | 2 + src/lib/oauth/index.ts | 10 ++-- src/pages/api/auth/login.ts | 2 +- src/pages/api/auth/oauth/discord.ts | 38 +++++++++++++-- src/pages/api/auth/oauth/github.ts | 36 ++++++++++++-- src/pages/api/auth/oauth/index.ts | 35 ++++++++++++++ src/pages/api/users.ts | 4 +- src/pages/dashboard/manage.tsx | 2 +- 9 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 src/pages/api/auth/oauth/index.ts diff --git a/src/components/pages/Manage/index.tsx b/src/components/pages/Manage/index.tsx index 762c2e8..46418ae 100644 --- a/src/components/pages/Manage/index.tsx +++ b/src/components/pages/Manage/index.tsx @@ -22,12 +22,15 @@ import { CheckIcon, CrossIcon, DeleteIcon, + DiscordIcon, FlameshotIcon, + GitHubIcon, RefreshIcon, SettingsIcon, ShareXIcon, } from 'components/icons'; import DownloadIcon from 'components/icons/DownloadIcon'; +import TrashIcon from 'components/icons/TrashIcon'; import Link from 'components/Link'; import MutedText from 'components/MutedText'; import { SmallTable } from 'components/SmallTable'; @@ -51,7 +54,17 @@ function ExportDataTooltip({ children }) { ); } -export default function Manage() { +export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers }) { + const oauth_providers = JSON.parse(raw_oauth_providers); + const icons = { + GitHub: GitHubIcon, + Discord: DiscordIcon, + }; + + for (const provider of oauth_providers) { + provider.Icon = icons[provider.name]; + } + const [user, setUser] = useRecoilState(userSelector); const modals = useModals(); @@ -290,6 +303,26 @@ export default function Manage() { } }; + const handleOauthUnlink = async () => { + const res = await useFetch('/api/auth/oauth', 'DELETE'); + if (res.error) { + showNotification({ + title: 'Error while unlinking from OAuth', + message: res.error, + color: 'red', + icon: , + }); + } else { + setUser(res); + showNotification({ + title: 'Unlinked from OAuth', + message: '', + color: 'green', + icon: , + }); + } + }; + const interval = useInterval(() => getExports(), 30000); useEffect(() => { getExports(); @@ -334,7 +367,31 @@ export default function Manage() { - + {oauth_registration && ( + + OAuth + Link your account with an OAuth provider. + + + {oauth_providers + .filter((x) => x.name.toLowerCase() !== user.oauthProvider) + .map(({ link_url, name, Icon }, i) => ( + + + + ))} + {user.oauth && user.oauthProvider && ( + + )} + + + )} + + Avatar { oauth_providers.push({ name: 'GitHub', url: '/api/auth/oauth/github', + link_url: '/api/auth/oauth/github?state=link', }); if (discEnabled) oauth_providers.push({ name: 'Discord', url: '/api/auth/oauth/discord', + link_url: '/api/auth/oauth/discord?state=link', }); return { diff --git a/src/lib/oauth/index.ts b/src/lib/oauth/index.ts index 01bc54c..8bed9bb 100644 --- a/src/lib/oauth/index.ts +++ b/src/lib/oauth/index.ts @@ -1,6 +1,8 @@ export const github_auth = { - oauth_url: (clientId: string) => - `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=user`, + oauth_url: (clientId: string, state?: string) => + `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${ + state ? `&state=${state}` : '' + }`, oauth_user: async (access_token: string) => { const res = await fetch('https://api.github.com/user', { headers: { @@ -14,10 +16,10 @@ export const github_auth = { }; export const discord_auth = { - oauth_url: (clientId: string, origin: string) => + oauth_url: (clientId: string, origin: string, state?: string) => `https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent( `${origin}/api/auth/oauth/discord` - )}&response_type=code&scope=identify`, + )}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`, oauth_user: async (access_token: string) => { const res = await fetch('https://discord.com/api/users/@me', { headers: { diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index 4d584d3..1c52fa3 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -36,7 +36,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { if (!valid) return res.forbid('Wrong password'); res.setCookie('user', user.id, { - sameSite: true, + sameSite: 'lax', expires: new Date(Date.now() + 6.048e8 * 2), path: '/', }); diff --git a/src/pages/api/auth/oauth/discord.ts b/src/pages/api/auth/oauth/discord.ts index ffb9ed8..0676a00 100644 --- a/src/pages/api/auth/oauth/discord.ts +++ b/src/pages/api/auth/oauth/discord.ts @@ -13,12 +13,13 @@ async function handler(req: NextApiReq, res: NextApiRes) { return res.bad('Discord OAuth is not configured'); } - const { code } = req.query as { code: string }; + const { code, state } = req.query as { code: string; state?: string }; if (!code) return res.redirect( discord_auth.oauth_url( config.oauth.discord_client_id, - `${config.core.https ? 'https' : 'http'}://${req.headers.host}` + `${config.core.https ? 'https' : 'http'}://${req.headers.host}`, + state ) ); @@ -55,7 +56,34 @@ async function handler(req: NextApiReq, res: NextApiRes) { }, }); - if (existing && existing.oauth && existing.oauthProvider === 'discord') { + if (state && state === 'link') { + const user = await req.user(); + if (!user) return res.error('not logged in, unable to link account'); + + if (user.oauth && user.oauthProvider === 'discord') + return res.error('account already linked with discord'); + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + oauth: true, + oauthProvider: 'discord', + oauthAccessToken: json.access_token, + avatar: avatarBase64, + }, + }); + req.cleanCookie('user'); + res.setCookie('user', user.id, { + sameSite: 'lax', + expires: new Date(Date.now() + 6.048e8 * 2), + path: '/', + }); + Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(discord)`); + + return res.redirect('/'); + } else if (existing && existing.oauth && existing.oauthProvider === 'discord') { await prisma.user.update({ where: { id: existing.id, @@ -67,7 +95,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { req.cleanCookie('user'); res.setCookie('user', existing.id, { - sameSite: true, + sameSite: 'lax', expires: new Date(Date.now() + 6.048e8 * 2), path: '/', }); @@ -92,7 +120,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { req.cleanCookie('user'); res.setCookie('user', user.id, { - sameSite: true, + sameSite: 'lax', expires: new Date(Date.now() + 6.048e8 * 2), path: '/', }); diff --git a/src/pages/api/auth/oauth/github.ts b/src/pages/api/auth/oauth/github.ts index f3c1f7b..1311fd8 100644 --- a/src/pages/api/auth/oauth/github.ts +++ b/src/pages/api/auth/oauth/github.ts @@ -13,9 +13,9 @@ async function handler(req: NextApiReq, res: NextApiRes) { return res.bad('GitHub OAuth is not configured'); } - const { code } = req.query as { code: string }; + const { code, state } = req.query as { code: string; state: string }; - if (!code) return res.redirect(github_auth.oauth_url(config.oauth.github_client_id)); + if (!code) return res.redirect(github_auth.oauth_url(config.oauth.github_client_id, state)); const resp = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', @@ -47,7 +47,33 @@ async function handler(req: NextApiReq, res: NextApiRes) { }, }); - if (existing && existing.oauth && existing.oauthProvider === 'github') { + if (state && state === 'link') { + const user = await req.user(); + if (!user) return res.error('not logged in, unable to link account'); + + if (user.oauth && user.oauthProvider === 'github') return res.error('account already linked with github'); + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + oauth: true, + oauthProvider: 'github', + oauthAccessToken: json.access_token, + avatar: avatarBase64, + }, + }); + req.cleanCookie('user'); + res.setCookie('user', user.id, { + sameSite: 'lax', + expires: new Date(Date.now() + 6.048e8 * 2), + path: '/', + }); + Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(github)`); + + return res.redirect('/'); + } else if (existing && existing.oauth && existing.oauthProvider === 'github') { await prisma.user.update({ where: { id: existing.id, @@ -59,7 +85,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { req.cleanCookie('user'); res.setCookie('user', existing.id, { - sameSite: true, + sameSite: 'lax', expires: new Date(Date.now() + 6.048e8 * 2), path: '/', }); @@ -84,7 +110,7 @@ async function handler(req: NextApiReq, res: NextApiRes) { req.cleanCookie('user'); res.setCookie('user', user.id, { - sameSite: true, + sameSite: 'lax', expires: new Date(Date.now() + 6.048e8 * 2), path: '/', }); diff --git a/src/pages/api/auth/oauth/index.ts b/src/pages/api/auth/oauth/index.ts new file mode 100644 index 0000000..b00c303 --- /dev/null +++ b/src/pages/api/auth/oauth/index.ts @@ -0,0 +1,35 @@ +import prisma from 'lib/prisma'; +import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline'; + +async function handler(req: NextApiReq, res: NextApiRes) { + const user = await req.user(); + if (!user) return res.error('not logged in'); + + if (req.method === 'DELETE') { + if (!user.password) + return res.forbid("can't unlink account without a password, please set one then unlink."); + + const nuser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + oauth: false, + oauthProvider: null, + oauthAccessToken: null, + }, + }); + + delete nuser.password; + + return res.json(nuser); + } else { + return res.json({ + enabled: user.oauth, + provider: user.oauthProvider, + access_token: user.oauthAccessToken, + }); + } +} + +export default withZipline(handler); diff --git a/src/pages/api/users.ts b/src/pages/api/users.ts index fef7da0..7075c12 100644 --- a/src/pages/api/users.ts +++ b/src/pages/api/users.ts @@ -41,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes) { }); for (let i = 0; i !== files.length; ++i) { - await datasource.delete(files[i].file); + try { + await datasource.delete(files[i].file); + } catch (e) {} } const { count } = await prisma.image.deleteMany({ diff --git a/src/pages/dashboard/manage.tsx b/src/pages/dashboard/manage.tsx index 876f258..fdff0d0 100644 --- a/src/pages/dashboard/manage.tsx +++ b/src/pages/dashboard/manage.tsx @@ -18,7 +18,7 @@ export default function ManagePage(props) { {title} - + );