feat: separate user and admin auth, redirect to settings after invite

This commit is contained in:
Isaac
2025-02-09 23:12:53 +00:00
parent 05c6ffa482
commit 2255d0d15d
8 changed files with 265 additions and 305 deletions

View File

@@ -1,14 +1,16 @@
const fastify = require('fastify')({ trustProxy: process.env.HTTP_TRUST_PROXY === 'true' });
const oauth = require('@fastify/oauth2');
const { randomBytes } = require('crypto');
const { short } = require('leeks.js');
const { join } = require('path');
const { files } = require('node-dir');
const { getPrivilegeLevel } = require('./lib/users');
const { format } = require('util');
process.env.ORIGIN = process.env.HTTP_INTERNAL || process.env.HTTP_EXTERNAL;
module.exports = async client => {
// for file uploads
fastify.register(require('@fastify/multipart'), { limits: { fileSize: 2**27 } }); // 128 MiB
// cookies plugin, must be registered before oauth2 since oauth2@7.2.0
fastify.register(require('@fastify/cookie'));
@@ -21,36 +23,6 @@ module.exports = async client => {
secret: process.env.ENCRYPTION_KEY,
});
// oauth2 plugin
fastify.states = new Map();
fastify.register(oauth, {
callbackUri: `${process.env.HTTP_EXTERNAL}/auth/callback`,
callbackUriParams: { prompt: 'none' },
checkStateFunction: async req => {
if (req.query.state !== req.cookies['oauth2-redirect-state']) {
throw new Error('Invalid state');
}
return true;
},
credentials: {
auth: oauth.DISCORD_CONFIGURATION,
client: {
id: client.user.id,
secret: process.env.DISCORD_SECRET,
},
},
generateStateFunction: req => {
const state = randomBytes(8).toString('hex');
fastify.states.set(state, req.query.r);
return state;
},
name: 'discord',
redirectStateCookieName: 'oauth2-redirect-state',
scope: ['applications.commands.permissions.update', 'guilds', 'identify'],
startRedirectPath: '/auth/login',
});
// auth
fastify.decorate('authenticate', async (req, res) => {
try {
@@ -111,7 +83,14 @@ module.exports = async client => {
error: 'Unavailable For Legal Reasons',
message: 'This guild has been banned for breaking the terms of service.',
statusCode: 451,
});
}
if (!req.user.scopes?.includes('applications.commands.permissions.update')) {
return res.code(401).send({
elevate: 'admin',
error: 'Unauthorised',
message: 'Extra scopes required; reauthenticate.',
statusCode: 401,
});
}
const guildMember = await guild.members.fetch(userId);
@@ -161,11 +140,15 @@ module.exports = async client => {
: '&a') + responseTime + 'ms';
const level = req.routeOptions.url === '/status'
? 'debug'
: req.routeOptions.url === '/*'
: req.routeOptions.url === '/*'
? 'verbose'
: 'info';
client.log[level].http(short(`${req.id} ${req.ip} ${req.method} ${req.routeOptions.url ?? '*'} &m-+>&r ${status}&b in ${responseTime}`));
if (!req.routeOptions.url) client.log.verbose.http(`${req.id} ${req.method} ${req.url}`);
client.log[level].http(
format(
short(`${req.id} ${req.ip} ${req.method} %s &m-+>&r ${status}&b in ${responseTime}`),
req.url,
),
);
done();
});

View File

@@ -1 +0,0 @@
module.exports.domain = new URL(process.env.HTTP_EXTERNAL).hostname;

View File

@@ -1,7 +1,27 @@
const { domain } = require('../../lib/http');
module.exports.get = () => ({
handler: async function (req, res) { // MUST NOT use arrow function syntax
handler: async function (req, res) {
const cookie = req.cookies['oauth2-state'];
if (!cookie) {
return res.code(400).send({
error: 'Bad Request',
message: 'State is missing.',
statusCode: 400,
});
}
const state = new URLSearchParams(cookie);
if (state.get('secret') !== req.query.state) {
return res.code(400).send({
error: 'Bad Request',
message: 'Invalid state.',
statusCode: 400,
});
}
// TODO: check if req.query.permissions are correct
const data = await (await fetch('https://discord.com/api/oauth2/token', {
body: new URLSearchParams({
client_id: req.routeOptions.config.client.user.id,
@@ -14,22 +34,30 @@ module.exports.get = () => ({
method: 'POST',
})).json();
const user = await (await fetch('https://discordapp.com/api/users/@me', { headers: { 'Authorization': `Bearer ${data.access_token}` } })).json();
const redirect = (data.guild?.id && `/settings/${data.guild?.id}`) || state.get('redirect') || '/';
const bearerOptions = { headers: { 'Authorization': `Bearer ${data.access_token}` } };
const user = await (await fetch('https://discordapp.com/api/users/@me', bearerOptions)).json();
let scopes;
if (data.scope) {
scopes = data.scope.split(' ');
} else {
const auth = await (await fetch('https://discordapp.com/api/oauth2/@me', bearerOptions)).json();
scopes = auth.scopes;
}
const token = this.jwt.sign({
accessToken: data.access_token,
avatar: user.avatar,
expiresAt: Date.now() + (data.expires_in * 1000),
id: user.id,
locale: user.locale,
scopes,
username: user.username,
});
// note: if data.guild is present, guild_id and permissions should also be in req.query
const redirect = this.states.get(req.query.state) || (data.guild?.id && `/settings/${data.guild?.id}`) || '/';
this.states.delete(req.query.state);
res.setCookie('token', token, {
domain,
httpOnly: true,
maxAge: data.expires_in,
path: '/',

44
src/routes/auth/login.js Normal file
View File

@@ -0,0 +1,44 @@
const { randomBytes } = require('crypto');
module.exports.get = () => ({
handler: async function (req, res) {
const { client } = req.routeOptions.config;
const state = new URLSearchParams({
redirect: req.query.r ?? '',
secret: randomBytes(8).toString('hex'),
});
res.setCookie('oauth2-state', state.toString(), {
httpOnly: true,
sameSite: 'lax',
});
const params = {
client_id: client.user.id,
// ? prompt: 'none',
redirect_uri: `${process.env.HTTP_EXTERNAL}/auth/callback`, // if not set defaults to first allowed
response_type: 'code',
scope: 'guilds identify',
state: state.get('secret'),
};
if (req.query.invite !== undefined) {
params.prompt = 'consent'; // already implied by the bot scope
params.scope = 'applications.commands applications.commands.permissions.update bot ' + params.scope;
params.integration_type = '0';
params.permissions = '268561488';
if (req.query.guild) {
params.guild_id = req.query.guild;
params.disable_guild_select = 'true';
}
} else if (req.query.role === 'admin') { // invite implies admin already
params.scope = 'applications.commands.permissions.update ' + params.scope;
}
const url = new URL('https://discord.com/oauth2/authorize');
url.search = new URLSearchParams(params);
res.redirect(url.toString());
},
});

View File

@@ -1,5 +1,3 @@
const { domain } = require('../../lib/http');
module.exports.get = fastify => ({
handler: async function (req, res) {
const { accessToken } = req.user;
@@ -15,7 +13,6 @@ module.exports.get = fastify => ({
});
res.clearCookie('token', {
domain,
httpOnly: true,
path: '/',
sameSite: 'Strict',

View File

@@ -1,29 +1,5 @@
const { randomBytes } = require('crypto');
module.exports.get = () => ({
handler: async function (req, res) {
const { client } = req.routeOptions.config;
const state = randomBytes(8).toString('hex');
this.states.set(state, null);
const url = new URL('https://discord.com/oauth2/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', client.user.id);
url.searchParams.set('prompt', 'none');
url.searchParams.set('redirect_uri', `${process.env.HTTP_EXTERNAL}/auth/callback`); // window.location.origin
url.searchParams.set('scope', 'applications.commands applications.commands.permissions.update bot guilds identify');
url.searchParams.set('permissions', '268561488');
if (req.query.guild) {
url.searchParams.set('guild_id', req.query.guild);
url.searchParams.set('disable_guild_select', 'true');
}
res.setCookie('oauth2-redirect-state', state, {
httpOnly: true,
sameSite: 'lax',
});
res.redirect(url.toString());
res.redirect(`/auth/login?invite&guild=${req.query.guild || ''}`);
},
});