feat: ability to link existing accounts to oauth

This commit is contained in:
diced 2022-10-29 20:02:54 -07:00
parent 0847802ce4
commit 561849ae5b
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
9 changed files with 171 additions and 19 deletions

View file

@ -22,12 +22,15 @@ import {
CheckIcon, CheckIcon,
CrossIcon, CrossIcon,
DeleteIcon, DeleteIcon,
DiscordIcon,
FlameshotIcon, FlameshotIcon,
GitHubIcon,
RefreshIcon, RefreshIcon,
SettingsIcon, SettingsIcon,
ShareXIcon, ShareXIcon,
} from 'components/icons'; } from 'components/icons';
import DownloadIcon from 'components/icons/DownloadIcon'; import DownloadIcon from 'components/icons/DownloadIcon';
import TrashIcon from 'components/icons/TrashIcon';
import Link from 'components/Link'; import Link from 'components/Link';
import MutedText from 'components/MutedText'; import MutedText from 'components/MutedText';
import { SmallTable } from 'components/SmallTable'; 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 [user, setUser] = useRecoilState(userSelector);
const modals = useModals(); 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: <CrossIcon />,
});
} else {
setUser(res);
showNotification({
title: 'Unlinked from OAuth',
message: '',
color: 'green',
icon: <CheckIcon />,
});
}
};
const interval = useInterval(() => getExports(), 30000); const interval = useInterval(() => getExports(), 30000);
useEffect(() => { useEffect(() => {
getExports(); getExports();
@ -334,7 +367,31 @@ export default function Manage() {
</Group> </Group>
</form> </form>
<Box mb='md'> {oauth_registration && (
<Box my='md'>
<Title>OAuth</Title>
<MutedText size='md'>Link your account with an OAuth provider.</MutedText>
<Group>
{oauth_providers
.filter((x) => x.name.toLowerCase() !== user.oauthProvider)
.map(({ link_url, name, Icon }, i) => (
<Link key={i} href={link_url} passHref legacyBehavior>
<Button size='lg' leftIcon={<Icon />} component='a' my='sm'>
Link account with {name}
</Button>
</Link>
))}
{user.oauth && user.oauthProvider && (
<Button onClick={handleOauthUnlink} size='lg' leftIcon={<TrashIcon />} my='sm' color='red'>
Unlink account with {user.oauthProvider[0].toUpperCase() + user.oauthProvider.slice(1)}
</Button>
)}
</Group>
</Box>
)}
<Box my='md'>
<Title>Avatar</Title> <Title>Avatar</Title>
<FileInput <FileInput
placeholder='Click to upload a file' placeholder='Click to upload a file'

View file

@ -13,11 +13,13 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
oauth_providers.push({ oauth_providers.push({
name: 'GitHub', name: 'GitHub',
url: '/api/auth/oauth/github', url: '/api/auth/oauth/github',
link_url: '/api/auth/oauth/github?state=link',
}); });
if (discEnabled) if (discEnabled)
oauth_providers.push({ oauth_providers.push({
name: 'Discord', name: 'Discord',
url: '/api/auth/oauth/discord', url: '/api/auth/oauth/discord',
link_url: '/api/auth/oauth/discord?state=link',
}); });
return { return {

View file

@ -1,6 +1,8 @@
export const github_auth = { export const github_auth = {
oauth_url: (clientId: string) => oauth_url: (clientId: string, state?: string) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=user`, `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${
state ? `&state=${state}` : ''
}`,
oauth_user: async (access_token: string) => { oauth_user: async (access_token: string) => {
const res = await fetch('https://api.github.com/user', { const res = await fetch('https://api.github.com/user', {
headers: { headers: {
@ -14,10 +16,10 @@ export const github_auth = {
}; };
export const discord_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( `https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/discord` `${origin}/api/auth/oauth/discord`
)}&response_type=code&scope=identify`, )}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
oauth_user: async (access_token: string) => { oauth_user: async (access_token: string) => {
const res = await fetch('https://discord.com/api/users/@me', { const res = await fetch('https://discord.com/api/users/@me', {
headers: { headers: {

View file

@ -36,7 +36,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!valid) return res.forbid('Wrong password'); if (!valid) return res.forbid('Wrong password');
res.setCookie('user', user.id, { res.setCookie('user', user.id, {
sameSite: true, sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2), expires: new Date(Date.now() + 6.048e8 * 2),
path: '/', path: '/',
}); });

View file

@ -13,12 +13,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.bad('Discord OAuth is not configured'); 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) if (!code)
return res.redirect( return res.redirect(
discord_auth.oauth_url( discord_auth.oauth_url(
config.oauth.discord_client_id, 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({ await prisma.user.update({
where: { where: {
id: existing.id, id: existing.id,
@ -67,7 +95,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user'); req.cleanCookie('user');
res.setCookie('user', existing.id, { res.setCookie('user', existing.id, {
sameSite: true, sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2), expires: new Date(Date.now() + 6.048e8 * 2),
path: '/', path: '/',
}); });
@ -92,7 +120,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user'); req.cleanCookie('user');
res.setCookie('user', user.id, { res.setCookie('user', user.id, {
sameSite: true, sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2), expires: new Date(Date.now() + 6.048e8 * 2),
path: '/', path: '/',
}); });

View file

@ -13,9 +13,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.bad('GitHub OAuth is not configured'); 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', { const resp = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST', 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({ await prisma.user.update({
where: { where: {
id: existing.id, id: existing.id,
@ -59,7 +85,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user'); req.cleanCookie('user');
res.setCookie('user', existing.id, { res.setCookie('user', existing.id, {
sameSite: true, sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2), expires: new Date(Date.now() + 6.048e8 * 2),
path: '/', path: '/',
}); });
@ -84,7 +110,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user'); req.cleanCookie('user');
res.setCookie('user', user.id, { res.setCookie('user', user.id, {
sameSite: true, sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2), expires: new Date(Date.now() + 6.048e8 * 2),
path: '/', path: '/',
}); });

View file

@ -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);

View file

@ -41,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}); });
for (let i = 0; i !== files.length; ++i) { 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({ const { count } = await prisma.image.deleteMany({

View file

@ -18,7 +18,7 @@ export default function ManagePage(props) {
<title>{title}</title> <title>{title}</title>
</Head> </Head>
<Layout props={props}> <Layout props={props}>
<Manage /> <Manage oauth_providers={props.oauth_providers} oauth_registration={props.oauth_registration} />
</Layout> </Layout>
</> </>
); );