Discord account linking

This commit is contained in:
Jonathan Barrow 2021-11-20 17:41:17 -05:00
parent ad11cd4ee1
commit 4079e3f99a
11 changed files with 3184 additions and 235 deletions

View File

@ -1,5 +1,6 @@
{
"env": {
"browser": true,
"node": true,
"commonjs": true,
"es6": true

View File

@ -10,6 +10,11 @@
},
"discord": {
"client_id": "client_id",
"client_secret": "client_secret"
"client_secret": "client_secret",
"guild_id": "Guild ID",
"bot_token": "token",
"tester_roles": [
"role id"
]
}
}

2808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,15 +19,16 @@
"dependencies": {
"colors": "^1.4.0",
"cookie-parser": "^1.4.5",
"discord-oauth2": "github:ryanblenis/discord-oauth2",
"express": "^4.17.1",
"express-handlebars": "^4.0.4",
"express-locale": "^2.0.0",
"fs-extra": "^9.1.0",
"got": "^11.8.2",
"gray-matter": "^4.0.3",
"ioredis": "^4.26.0",
"marked": "^3.0.4",
"morgan": "^1.10.0",
"node-fetch": "^3.0.0",
"redis-json": "^5.0.0",
"trello": "^0.10.0"
},

View File

@ -126,6 +126,13 @@ fieldset {
color: var(--text);
}
.setting-card #remove-discord-connection {
width: 100%;
padding: 12px 48px;
cursor: pointer;
background: #383f6b;
}
.setting-card.sign-in-history a button {
width: 100%;
padding: 12px 48px;
@ -153,7 +160,7 @@ fieldset {
.account-link-notice {
display: flex;
justify-content: center;
position: absolute;
position: fixed;
top: -150px;
width: 100%;
animation: account-link-notice 5s;
@ -173,6 +180,29 @@ fieldset {
z-index: 3;
}
.account-error-notice {
display: flex;
justify-content: center;
position: fixed;
top: -150px;
width: 100%;
animation: account-error-notice 5s;
}
@keyframes account-error-notice {
0% {top: -150px}
20% {top: 35px}
80% {top: 35px}
100% {top: -150px}
}
.account-error-notice div {
background: #A9375B;
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
footer {
margin-top: 80px;
}

View File

@ -0,0 +1,22 @@
document.getElementById('remove-discord-connection').addEventListener('click', () => {
// TODO: Refresh access token if expired (move this to the backend maybe?)
const tokenType = document.cookie.split('; ').find(row => row.startsWith('token_type=')).split('=')[1];
const accessToken = document.cookie.split('; ').find(row => row.startsWith('access_token=')).split('=')[1];
fetch('https://api.pretendo.cc/v1/connections/remove/discord', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `${tokenType} ${decodeURIComponent(accessToken)}`
}
})
.then(response => response.json())
.then(({ status }) => {
if (status === 200) {
location.reload();
}
})
.catch(console.log);
});

View File

@ -1,185 +1,277 @@
const { Router } = require('express');
const crypto = require('crypto');
const DiscordOauth2 = require('discord-oauth2');
const util = require('../util');
const router = new Router();
const fetch = (...args) =>
import('node-fetch').then(({ default: fetch }) => fetch(...args));
const config = require('../../config.json');
const router = new Router();
// A test PNID
const account = {
access_level: 0, // 0: standard, 1: tester, 2: mod?, 3. dev?, 4. god
server_access_level: 'prod', // prod, test, dev
username: 'PN_Monty',
password: 'thelegend27',
birthdate: '13/04/2004', // what format
gender: 'what even is this', // what format
country: 'Italy', // what format
language: 'JS smh', // what format
email: {
address: 'notascam@freecreditreport.com',
primary: true, // do we need to let the user change anything?
parent: true,
reachable: true,
validated: true,
},
region: 'don\'t know the numbers',
timezone: {
name: 'Europe/Rome',
offset: 3600, // haven't checked if this is correct, just an assumption
},
mii: {
name: 'Monty',
image_url:
'https://studio.mii.nintendo.com/miis/image.png?data=00080f50595a606a6268696f757883969b9aa1a8b1b8b7bebdc5cccbd1d8620a121a181119111916222d3444484c4b&type=face&expression=normal&width=512',
},
flags: {
marketing: true,
off_device: true, // Forgot what this does
},
devices: [
{
is_emulator: {
type: false,
},
console_type: {
type: 'wup',
},
device_attributes: {
created_date: '23/07/2021', // what format
name: 'Wii U', // ?
value: 'what', // what format
},
},
{
is_emulator: {
type: false,
},
console_type: {
type: 'wup',
},
device_attributes: {
created_date: '24/05/2020',
name: 'Windows', // ?
value: 'what',
},
},
],
connections: { // This needs to be added to the schema
discord: {
id: '406125028065804289',
access_token: 'GiJ2Osi6LpYS7uLgZNyvCbRtpRopv1', // This only has the identify scope so eh, who cares if it gets leaked
expires_on: 604800,
refresh_token: 'VeQMa6zp2Rx77PjhiNFJbpKrpz2gX2',
scope: 'identify',
token_type: 'Bearer',
},
},
};
// Create OAuth client
const discordOAuth = new DiscordOauth2({
clientId: config.discord.client_id,
clientSecret: config.discord.client_secret,
redirectUri: `${config.http.base_url}/account/connect/discord`,
version: 'v9'
});
router.get('/', async (request, response) => {
const justLinked = request.cookies.justLinked;
const reqLocale = request.locale;
const locale = util.getLocale(reqLocale.region, reqLocale.language);
let discordUser;
if (account.connections.discord.access_token) {
// TODO: check if the token is valid, if not refresh it.
const discord = account.connections.discord;
const fetchDiscordUser = await fetch('https://discord.com/api/users/@me', {
method: 'get',
headers: {
'Authorization': `${discord.token_type} ${discord.access_token}`
},
});
discordUser = await fetchDiscordUser.json();
// Verify the user is logged in
if (!request.cookies.access_token || !request.cookies.refresh_token) {
return response.redirect('/account/login');
}
// test user
discordUser = {
id: '406125028065804289',
username: 'montylion',
avatar: '5184b3dc388bdad1a93eb58694a58398',
discriminator: '3581',
public_flags: 64,
flags: 64,
banner: null,
banner_color: '#ff7081',
accent_color: 16740481,
locale: 'en-US',
mfa_enabled: true,
};
const isTester = () => {
return account.access_level > 0;
};
response.clearCookie('justLinked');
// TODO: make the sign in history only show the first 2/4 devices
response.render('account', {
// Setup the data to be sent to the handlebars renderer
const renderData = {
layout: 'main',
locale,
localeString: reqLocale.toString(),
account,
isTester,
discordUser,
justLinked
locale: util.getLocale(request.locale.region, request.locale.language),
localeString: request.locale.toString(),
linked: request.cookies.linked,
error: request.cookies.error
};
// Reset message cookies
response.clearCookie('linked');
response.clearCookie('error');
// Attempt to get user data
let apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token);
response.cookie('access_token', tokens.access_token);
response.cookie('token_type', tokens.token_type);
apiResponse = await util.apiGetRequest('/v1/user', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
});
}
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
// Set user account info to render data
const account = apiResponse.body;
renderData.account = account;
renderData.isTester = account.access_level > 0;
// Check if a Discord account is linked to the PNID
if (account.connections.discord.id && account.connections.discord.id.trim() !== '') {
// If Discord account is linked, then get user info
try {
renderData.discordUser = await discordOAuth.getUser(account.connections.discord.access_token);
} catch (error) {
// Assume expired, refresh and retry Discord request
let tokens;
try {
tokens = await discordOAuth.tokenRequest({
scope: 'identify guilds',
grantType: 'refresh_token',
refreshToken: account.connections.discord.refresh_token,
});
} catch (error) {
renderData.error = 'Invalid Discord refresh token. Remove account and relink';
response.render('account', renderData);
}
// TODO: Add a dedicated endpoint for updating connections?
apiResponse = await util.apiPostGetRequest('/v1/connections/add/discord', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
}, {
data: {
id: account.connections.discord.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}
});
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token);
response.cookie('access_token', tokens.access_token);
response.cookie('token_type', tokens.token_type);
apiResponse = await util.apiPostGetRequest('/v1/connections/add/discord', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
}, {
data: {
id: account.connections.discord.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}
});
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
}
account.connections.discord.access_token = tokens.access_token;
account.connections.discord.refresh_token = tokens.refresh_token;
}
// Get the users Discord roles to check if they are a tester
const { roles } = await discordOAuth.getMemberRolesForGuild({
userId: account.connections.discord.id,
guildId: config.discord.guild_id,
botToken: config.discord.bot_token
});
// Only run this check if not already a tester (edge case)
if (!renderData.isTester) {
// 409116477212459008 = Developer
// 882247322933801030 = Super Mario (Patreon tier)
renderData.isTester = roles.some(role => config.discord.tester_roles.includes(role));
}
} else {
// If no Discord account linked, generate an auth URL
const discordAuthURL = discordOAuth.generateAuthUrl({
scope: ['identify', 'guilds'],
state: crypto.randomBytes(16).toString('hex'),
});
renderData.discordAuthURL = discordAuthURL;
}
response.render('account', renderData);
});
router.get('/login', async (request, response) => {
response.render('account_login');
});
router.post('/login', async (request, response) => {
const { username, password } = request.body;
const apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
username,
password,
grant_type: 'password'
});
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token);
response.cookie('access_token', tokens.access_token);
response.cookie('token_type', tokens.token_type);
response.redirect('/account');
});
router.get('/connect/discord', async (request, response) => {
//const reqLocale = request.locale;
//const locale = util.getLocale(reqLocale.region, reqLocale.language);
const codeExchange = await fetch('https://discord.com/api/oauth2/token', {
method: 'post',
body: new URLSearchParams({
client_id: config.discord.client_id,
client_secret: config.discord.client_secret,
grant_type: 'authorization_code',
let tokens;
try {
// Attempt to get OAuth2 tokens
tokens = await discordOAuth.tokenRequest({
code: request.query.code,
redirect_uri: `${config.http.base_url}/account/connect/discord`,
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const token = await codeExchange.json();
scope: 'identify guilds',
grantType: 'authorization_code',
});
} catch (error) {
response.cookie('error', 'Invalid Discord authorization code. Please try again');
return response.redirect('/account');
}
/*
// Send the oauth tokens to the account server
fetch('account server endpoint', {
method: 'post',
body: {
access_token: token.access_token,
token_type: token.token_type,
expires_on: new Date().getTime() + token.expires_in,
refresh_token: token.refresh_token,
scope: token.scope,
},
headers: {
'Content-Type': 'application/json',
// Get Discord user data
const user = await discordOAuth.getUser(tokens.access_token);
// Link the Discord account to the PNID
let apiResponse = await util.apiPostGetRequest('/v1/connections/add/discord', {
'Authorization': `${request.cookies.token_type} ${request.cookies.access_token}`
}, {
data: {
id: user.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}
});
*/
/* Token refresh function (should be moved to util.js)
const refreshTokens = await fetch('https://discord.com/api/oauth2/token', {
method: 'post',
body: new URLSearchParams({
client_id: config.discord.client_id,
client_secret: config.discord.client_secret,
grant_type: 'refresh_token',
refresh_token: account.connections.discord.refresh_token
}),
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});
const newTokens = await refreshTokens.json();
console.log(newTokens);
*/
if (apiResponse.statusCode !== 200) {
// Assume expired, refresh and retry request
apiResponse = await util.apiPostGetRequest('/v1/login', {}, {
refresh_token: request.cookies.refresh_token,
grant_type: 'refresh_token'
});
// This sets a cookie to tell the account page to show the "Account linked successfully" notice, and redirects.
response.cookie('justLinked', 'discord').redirect('/account');
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
const tokens = apiResponse.body;
response.cookie('refresh_token', tokens.refresh_token);
response.cookie('access_token', tokens.access_token);
response.cookie('token_type', tokens.token_type);
apiResponse = await util.apiPostGetRequest('/v1/connections/add/discord', {
'Authorization': `${tokens.token_type} ${tokens.access_token}`
}, {
data: {
id: user.id,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}
});
// If still failed, something went horribly wrong
if (apiResponse.statusCode !== 200) {
// TODO: Error message
return response.status(apiResponse.statusCode).json({
error: 'Bad'
});
}
}
response.cookie('linked', 'Discord').redirect('/account');
});
module.exports = router;
module.exports = router;

View File

@ -14,6 +14,7 @@ const app = express();
logger.info('Setting up Middleware');
app.use(morgan('dev'));
app.use(express.urlencoded({ extended: true }));
logger.info('Setting up static public folder');
app.use(express.static('public'));
@ -65,9 +66,6 @@ app.use(expressLocale({
'default': 'en-US'
}));
app.use('/', routers.home);
app.use('/faq', routers.faq);
app.use('/progress', routers.progress);
@ -108,6 +106,12 @@ app.engine('handlebars', handlebars({
${htmlRight}
</div>
`;
},
eq(value1, value2) {
return value1 === value2;
},
neq(value1, value2) {
return value1 !== value2;
}
}
}));

View File

@ -1,4 +1,5 @@
const fs = require('fs-extra');
const got = require('got');
const logger = require('./logger');
function fullUrl(request) {
@ -17,7 +18,44 @@ function getLocale(region, language) {
return require(`${__dirname}/../locales/US_en.json`);
}
function apiGetRequest(path, headers) {
return got.get(`https://api.pretendo.cc${path}`, {
responseType: 'json',
throwHttpErrors: false,
https: {
rejectUnauthorized: false, // Needed for self-signed certificates on localhost testing
},
headers
});
}
function apiPostGetRequest(path, headers, json) {
return got.post(`https://api.pretendo.cc${path}`, {
responseType: 'json',
throwHttpErrors: false,
https: {
rejectUnauthorized: false, // Needed for self-signed certificates on localhost testing
},
headers,
json
});
}
function apiDeleteGetRequest(path, headers, json) {
return got.delete(`https://api.pretendo.cc${path}`, {
throwHttpErrors: false,
https: {
rejectUnauthorized: false, // Needed for self-signed certificates on localhost testing
},
headers,
json
});
}
module.exports = {
fullUrl,
getLocale
getLocale,
apiGetRequest,
apiPostGetRequest,
apiDeleteGetRequest
};

View File

@ -6,11 +6,11 @@
<div class="account-wrapper">
<div class="account-sidebar">
<div class="user">
<img src="{{account.mii.image_url}}&bgColor=3E446600" class="mii" />
<p class="miiname">{{account.mii.name}}</p>
<p class="username">PNID: {{account.username}}</p>
</div>
<div class="user">
<img src="{{account.mii.image_url}}" class="mii" />
<p class="miiname">{{account.mii.name}}</p>
<p class="username">PNID: {{account.username}}</p>
</div>
</div>
<div class="settings-wrapper">
@ -50,8 +50,8 @@
<h2 class="header">Servers</h2>
<fieldset {{#if isTester}}{{else}}disabled{{/if}}>
<form class="server-selection" id="server">
<input type="radio" id="prod" name="server_selection" value="prod">
<label for="prod">
<input type="radio" id="prod" name="server_selection" value="prod" checked="{{ eq account.server_access_level 'prod'}}">
<label for="prod">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="7.5 4.21 12 6.81 16.5 4.21"></polyline>
@ -62,8 +62,8 @@
</svg>
<h2>Production</h2>
</label>
<input type="radio" id="beta" name="server_selection" value="beta">
<label for="beta">
<input type="radio" id="beta" name="server_selection" value="beta" checked="{{ neq account.server_access_level 'prod'}}">
<label for="beta">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="2,21 22,21 14,11.5 14,5 10,3 10,11.5"></polygon>
</svg>
@ -73,15 +73,14 @@
</fieldset>
{{#if discordUser}}
{{#if isTester }}
<p>Connected as {{ discordUser.username }}#{{ discordUser.discriminator }}.</p>
<p>Connected as {{ discordUser.username }}#{{ discordUser.discriminator }}</p>
<button class="button secondary" id="remove-discord-connection">Remove Discord account</button>
{{else}}
<p>{{ discordUser.username }}#{{ discordUser.discriminator }} doesn't appear to be a @Tester account. Check that you have the role on Discord. [should probably fix the wording]</p>
<p>Beta servers are exclusive to testers. To become a tester, check us out on <a href="https://www.patreon.com/PretendoNetwork" target="_blank">Patreon</a> and link your Discord account to your Patreon and PNID accounts</p>
{{/if}}
{{else}}
<p>Beta servers are exclusive to testers. If you're a tester, connect your Discord account <a href="https://discord.com/api/oauth2/authorize?client_id=896508671742345266&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Faccount%2Fconnect%2Fdiscord&response_type=code&scope=identify">here</a>.</p>
<p>Beta servers are exclusive to testers. If you're already a tester, connect your Discord account <a href="{{ discordAuthURL }}">here</a>.</p>
{{/if}}
</div>
<h2 class="section-header" id="security">Sign in and security</h2>
@ -109,11 +108,11 @@
<h2 class="header">Sign in history</h2>
<ul class="setting-list">
{{#each account.devices }}
<li>
<li>
<p class="label">{{this.device_attributes.name}}</p>
<p class="value">{{this.device_attributes.created_date}}</p>
</li>
{{/each}}
{{/each}}
</ul>
<a href="/account/sign-in-history">
<button class="button secondary">View full sign in history</button>
@ -124,29 +123,30 @@
<div class="setting-card span-both-columns">
<h2 class="header">Newsletter</h2>
<form id="other">
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
<label for="marketing">Receive project updates via email (you can opt-out at any time)</label>
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
<label for="marketing">Receive project updates via email (you can opt-out at any time)</label>
</form>
</div>
</div>
</div>
{{> footer }}
</div>
{{#if justLinked}}
<div class="account-link-notice">
<div>
<p>Account linked successfully.</p>
</div>
{{#if linked}}
<div class="account-link-notice">
<div>
<p>{{ linked }} account linked successfully</p>
</div>
</div>
{{/if}}
<script>
// Selectes the server level from the user's PNID
{{ account.server_access_level }}.setAttribute('checked', true);
</script>
{{#if error}}
<div class="account-error-notice">
<div>
<p>{{ error }}</p>
</div>
</div>
{{/if}}
<script src="/assets/js/account.js"></script>

View File

@ -0,0 +1,14 @@
<form action="/account/login" method="post">
<div>
<label for="username">Username</label>
<input name="username" id="username">
</div>
<div>
<label for="password">Password</label>
<input name="password" id="password" type="password">
</div>
<input name="grant_type" id="grant_type" type="hidden" value="password">
<div>
<button>Login</button>
</div>
</form>