feat(client/api): validate ms users (#51372)

Co-authored-by: Muhammed Mustafa <MuhammedElruby@gmail.com>
This commit is contained in:
Tom
2023-08-26 07:57:02 -05:00
committed by GitHub
parent 0f9ba6e9a5
commit 9a1895d2e3
45 changed files with 927 additions and 332 deletions

View File

@@ -17,6 +17,7 @@ import fetch from 'node-fetch';
import jwt from 'jsonwebtoken';
import { jwtSecret } from '../../../../config/secrets';
import { challengeTypes } from '../../../../config/challenge-types';
import {
fixPartiallyCompletedChallengeItem,
@@ -36,8 +37,6 @@ import {
validateGeneratedExamSchema,
validateUserCompletedExamSchema
} from '../utils/exam-schemas';
import { isMicrosoftLearnLink } from '../../../../utils/validate';
import { getApiUrlFromTrophy } from '../utils/ms-learn-utils';
const log = debug('fcc:boot:challenges');
@@ -100,6 +99,14 @@ export default async function bootChallenge(app, done) {
api.post('/coderoad-challenge-completed', coderoadChallengeCompleted);
const msTrophyChallengeCompleted = createMsTrophyChallengeCompleted(app);
api.post(
'/ms-trophy-challenge-completed',
send200toNonUser,
msTrophyChallengeCompleted
);
app.use(api);
app.use(router);
done();
@@ -121,6 +128,10 @@ const savableChallenges = getChallenges()
.filter(challenge => challenge.challengeType === 14)
.map(challenge => challenge.id);
const msTrophyChallenges = getChallenges()
.filter(challenge => challenge.challengeType === challengeTypes.msTrophy)
.map(({ id, msTrophyId }) => ({ id, msTrophyId }));
export function buildUserUpdate(
user,
challengeId,
@@ -446,25 +457,6 @@ async function projectCompleted(req, res, next) {
}
}
const isMSTrophyProject = completedChallenge.challengeType === 18;
let isTrophyMissing = false;
if (isMSTrophyProject) {
if (!isMicrosoftLearnLink(completedChallenge.solution)) {
return res.status(403).json({
type: 'error',
message:
'You have not provided the valid links for us to inspect your work.'
});
}
try {
const mSLearnAPIUrl = getApiUrlFromTrophy(completedChallenge.solution);
isTrophyMissing = mSLearnAPIUrl ? !(await fetch(mSLearnAPIUrl)).ok : true;
} catch {
isTrophyMissing = true;
log(`Error verifying trophy: ${completedChallenge.solution}`);
}
}
try {
// This is an ugly hack to update `user.completedChallenges`
await user.getCompletedChallenges$().toPromise();
@@ -486,8 +478,7 @@ async function projectCompleted(req, res, next) {
return res.json({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate,
...(isMSTrophyProject && { isTrophyMissing })
completedDate: completedChallenge.completedDate
});
});
}
@@ -697,6 +688,77 @@ function createExamChallengeCompleted(app) {
};
}
function createMsTrophyChallengeCompleted(app) {
const { MsUsername } = app.models;
return async function msTrophyChallengeCompleted(req, res, next) {
const { user, body = {} } = req;
const { id = '' } = body;
try {
const msUser = await MsUsername.findOne({
where: { userId: user.id }
});
if (!msUser || !msUser.msUsername) {
throw new Error('Microsoft username not found.');
}
const { msUsername } = msUser;
const challenge = msTrophyChallenges.find(
challenge => challenge.id === id
);
if (!challenge) {
throw new Error('Challenge not found');
}
const { msTrophyId = '' } = challenge;
const msTrophyApiUrl = `https://learn.microsoft.com/api/gamestatus/achievements/${msTrophyId}?username=${msUsername}&locale=en-us`;
const msApiRes = await fetch(msTrophyApiUrl);
if (!msApiRes.ok) {
throw new Error('Unable to validate trophy');
}
const completedChallenge = pick(body, ['id']);
completedChallenge.solution = msTrophyApiUrl;
completedChallenge.completedDate = Date.now();
try {
await user.getCompletedChallenges$().toPromise();
} catch (e) {
return next(e);
}
const { alreadyCompleted, updateData } = buildUserUpdate(
user,
completedChallenge.id,
completedChallenge
);
user.updateAttributes(updateData, err => {
if (err) {
return next(err);
}
return res.json({
alreadyCompleted,
points: alreadyCompleted ? user.points : user.points + 1,
completedDate: completedChallenge.completedDate
});
});
} catch (e) {
return res.status(500).json({
type: 'error',
message: e.message
});
}
};
}
async function saveChallenge(req, res, next) {
const user = req.user;
const { savedChallenges = [] } = user;

View File

@@ -2,6 +2,7 @@ import debugFactory from 'debug';
import dedent from 'dedent';
import { body } from 'express-validator';
import { pick } from 'lodash';
import fetch from 'node-fetch';
import {
fixCompletedChallengeItem,
@@ -22,6 +23,7 @@ import {
createDeleteUserToken,
encodeUserToken
} from '../middlewares/user-token';
import { createDeleteMsUsername } from '../middlewares/ms-username';
import { deprecatedEndpoint } from '../utils/disabled-endpoints';
const log = debugFactory('fcc:boot:user');
@@ -35,15 +37,24 @@ function bootUser(app) {
const postDeleteAccount = createPostDeleteAccount(app);
const postUserToken = createPostUserToken(app);
const deleteUserToken = createDeleteUserToken(app);
const postMsUsername = createPostMsUsername(app);
const deleteMsUsername = createDeleteMsUsername(app);
api.get('/account', sendNonUserToHome, deprecatedEndpoint);
api.get('/account/unlink/:social', sendNonUserToHome, getUnlinkSocial);
api.get('/user/get-session-user', getSessionUser);
api.post('/account/delete', ifNoUser401, deleteUserToken, postDeleteAccount);
api.post(
'/account/delete',
ifNoUser401,
deleteUserToken,
deleteMsUsername,
postDeleteAccount
);
api.post(
'/account/reset-progress',
ifNoUser401,
deleteUserToken,
deleteMsUsername,
postResetProgress
);
api.post(
@@ -61,6 +72,14 @@ function bootUser(app) {
deleteUserTokenResponse
);
api.post('/user/ms-username', ifNoUser401, postMsUsername);
api.delete(
'/user/ms-username',
ifNoUser401,
deleteMsUsername,
deleteMsUsernameResponse
);
app.use(api);
}
@@ -93,8 +112,89 @@ function deleteUserTokenResponse(req, res) {
return res.send({ userToken: null });
}
function createPostMsUsername(app) {
const { MsUsername } = app.models;
return async function postMsUsername(req, res) {
// example msTranscriptUrl: https://learn.microsoft.com/en-us/users/mot01/transcript/8u6awert43q1plo
// the last part is the transcript ID
// the username is irrelevant, and retrieved from the MS API response
const { msTranscriptUrl } = req.body;
if (!msTranscriptUrl) {
return res
.status(400)
.send('Please include a Microsoft transcript URL in request');
}
const msTranscriptId = msTranscriptUrl.split('/').pop();
const msTranscriptApiUrl = `https://learn.microsoft.com/api/profiles/transcript/share/${msTranscriptId}`;
try {
const msApiRes = await fetch(msTranscriptApiUrl);
if (!msApiRes.ok) {
res.status(500);
throw new Error(
'An error occurred trying to get your Microsoft transcript'
);
}
const { userName } = await msApiRes.json();
if (!userName) {
res.status(500);
throw new Error(
'An error occured trying to link your Microsoft account'
);
}
// Don't create if username is used by another fCC account
const usernameUsed = await MsUsername.findOne({
where: { msUsername: userName }
});
if (usernameUsed) {
throw new Error('That username is already used');
}
await MsUsername.destroyAll({ userId: req.user.id });
const ttl = 900 * 24 * 60 * 60 * 1000;
const newMsUsername = await MsUsername.create({
ttl,
userId: req.user.id,
msUsername: userName
});
if (!newMsUsername?.id) {
res.status(500);
throw new Error(
'An error occured trying to link your Microsoft account'
);
}
return res.json({ msUsername: userName });
} catch (e) {
log(e);
return res.send(e.message);
}
};
}
function deleteMsUsernameResponse(req, res) {
if (!req.msUsernameDeleted) {
return res
.status(500)
.send('An error occurred trying to unlink your Microsoft username');
}
return res.send({ msUsername: null });
}
function createReadSessionUser(app) {
const { UserToken } = app.models;
const { MsUsername, UserToken } = app.models;
return async function getSessionUser(req, res, next) {
const queryUser = req.user;
@@ -113,6 +213,20 @@ function createReadSessionUser(app) {
return next(e);
}
let msUsername;
try {
const userId = queryUser?.id;
const msUser = userId
? await MsUsername.findOne({
where: { userId }
})
: null;
msUsername = msUser ? msUser.msUsername : undefined;
} catch (e) {
return next(e);
}
if (!queryUser || !queryUser.toJSON().username) {
// TODO: This should return an error status
return res.json({ user: {}, result: '' });
@@ -154,7 +268,8 @@ function createReadSessionUser(app) {
isEmailVerified: !!user.emailVerified,
...normaliseUserFields(user),
joinDate: user.id.getTimestamp(),
userToken: encodedUserToken
userToken: encodedUserToken,
msUsername
}
},
result: user.username

View File

@@ -0,0 +1,20 @@
import debugFactory from 'debug';
const log = debugFactory('fcc:boot:user');
export function createDeleteMsUsername(app) {
const { MsUsername } = app.models;
return async function deleteMsUsername(req, res, next) {
try {
await MsUsername.destroyAll({ userId: req.user.id });
req.msUsernameDeleted = true;
} catch (e) {
req.msUsernameDeleted = false;
log(
`An error occurred deleting Microsoft username for user with id ${req.user.id}`
);
}
next();
};
}

View File

@@ -43,6 +43,10 @@
"dataSource": "db",
"public": false
},
"MsUsername": {
"dataSource": "db",
"public": false
},
"RoleMapping": {
"dataSource": "db",
"public": false

View File

@@ -0,0 +1,20 @@
{
"name": "MsUsername",
"description": "Microsoft account usernames",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"properties": {},
"validations": [],
"relations": {
"user": {
"type": "belongsTo",
"model": "user",
"foreignKey": "userId"
}
},
"acls": [],
"methods": {}
}

View File

@@ -1,27 +0,0 @@
// TODO: port to new API!
const MS_LEARN_DOMAIN = 'learn.microsoft.com';
const mSLearnRegex = /^\/[^/]+\/training\/achievements\/([^/]+)$/;
export const getApiUrlFromTrophy = trophyUrlString => {
if (!trophyUrlString) return null;
let mSLearnUrl;
try {
mSLearnUrl = new URL(trophyUrlString);
} catch {
return null;
}
if (mSLearnUrl.protocol !== 'https:') return null;
if (mSLearnUrl.hostname !== MS_LEARN_DOMAIN) return null;
const match = mSLearnUrl.pathname.match(mSLearnRegex);
if (!match) return null;
const apiUrl = new URL(
`https://${MS_LEARN_DOMAIN}/api/gamestatus/achievements/${match[1]}`
);
apiUrl.searchParams.set('username', mSLearnUrl.searchParams.get('username'));
return apiUrl.href;
};

View File

@@ -1,50 +0,0 @@
const { getApiUrlFromTrophy } = require('./ms-learn-utils');
const validApiUrl =
'https://learn.microsoft.com/api/gamestatus/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01';
const validTrophyUrl =
'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B';
describe('ms-learn-utils', () => {
describe('getApiUrlFromTrophy', () => {
it('should return null if the trophy url is empty', () => {
expect(getApiUrlFromTrophy('')).toBeNull();
});
it('should return null if the protocol is wrong', () => {
expect(getApiUrlFromTrophy('http://learn.microsoft.com')).toBeNull();
});
it('should return null if the domain is wrong', () => {
expect(getApiUrlFromTrophy('https://learn.microsoft.co')).toBeNull();
});
it('should return null if the path is incomplete', () => {
expect(getApiUrlFromTrophy('https://learn.microsoft.com')).toBeNull();
expect(
getApiUrlFromTrophy(
'https://learn.microsoft.com/en-us/trainin/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01'
)
).toBeNull();
});
it('should add the username as a query param', () => {
const username = 'moT01';
const url = new URL(getApiUrlFromTrophy(validTrophyUrl));
expect(url.searchParams.get('username')).toBe(username);
});
it('should only add a single query param', () => {
const url = new URL(getApiUrlFromTrophy(validTrophyUrl));
// URLSearchParams.size is only supported in Node 19+
expect([...url.searchParams.keys()].length).toBe(1);
});
it('should append the trophy path to the api url', () => {
expect(getApiUrlFromTrophy(validTrophyUrl)).toBe(validApiUrl);
});
});
});