mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-09 21:01:20 -04:00
feat(client/api): validate ms users (#51372)
Co-authored-by: Muhammed Mustafa <MuhammedElruby@gmail.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
20
api-server/src/server/middlewares/ms-username.js
Normal file
20
api-server/src/server/middlewares/ms-username.js
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,10 @@
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"MsUsername": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
},
|
||||
"RoleMapping": {
|
||||
"dataSource": "db",
|
||||
"public": false
|
||||
|
||||
20
api-server/src/server/models/ms-username.json
Normal file
20
api-server/src/server/models/ms-username.json
Normal 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": {}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user