mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2025-12-25 02:14:11 -05:00
feat(api): port /confirm-email to new api (#54975)
Co-authored-by: Niraj Nandish <nirajnandish@icloud.com>
This commit is contained in:
committed by
GitHub
parent
2c611fb15b
commit
22e74e6406
@@ -46,10 +46,11 @@ export function superRequest(
|
||||
config: {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
setCookies?: string[];
|
||||
headers?: { referer: string };
|
||||
},
|
||||
options?: Options
|
||||
): request.Test {
|
||||
const { method, setCookies } = config;
|
||||
const { method, setCookies, headers } = config;
|
||||
const { sendCSRFToken = true } = options ?? {};
|
||||
|
||||
const req = requests[method](resource).set('Origin', ORIGIN);
|
||||
@@ -58,6 +59,10 @@ export function superRequest(
|
||||
void req.set('Cookie', getCookies(setCookies));
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
void req.set('Referer', headers.referer);
|
||||
}
|
||||
|
||||
const csrfToken = (setCookies && getCsrfToken(setCookies)) ?? '';
|
||||
if (sendCSRFToken) {
|
||||
void req.set('CSRF-Token', csrfToken);
|
||||
|
||||
@@ -36,7 +36,7 @@ import { deprecatedEndpoints } from './routes/deprecated-endpoints';
|
||||
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
|
||||
import { donateRoutes } from './routes/donate';
|
||||
import { emailSubscribtionRoutes } from './routes/email-subscription';
|
||||
import { settingRoutes } from './routes/settings';
|
||||
import { settingRoutes, settingRedirectRoutes } from './routes/settings';
|
||||
import { statusRoute } from './routes/status';
|
||||
import { userGetRoutes, userRoutes, userPublicGetRoutes } from './routes/user';
|
||||
import {
|
||||
@@ -180,6 +180,8 @@ export const build = async (
|
||||
fastify.log.info(`Swagger UI available at ${API_LOCATION}/documentation`);
|
||||
}
|
||||
|
||||
// redirectWithMessage must be registered before codeFlowAuth
|
||||
void fastify.register(redirectWithMessage);
|
||||
void fastify.register(codeFlowAuth);
|
||||
void fastify.register(prismaPlugin);
|
||||
void fastify.register(mobileAuth0Routes);
|
||||
@@ -188,6 +190,7 @@ export const build = async (
|
||||
}
|
||||
void fastify.register(challengeRoutes);
|
||||
void fastify.register(settingRoutes);
|
||||
void fastify.register(settingRedirectRoutes);
|
||||
void fastify.register(donateRoutes);
|
||||
void fastify.register(emailSubscribtionRoutes);
|
||||
void fastify.register(userRoutes);
|
||||
@@ -198,7 +201,6 @@ export const build = async (
|
||||
void fastify.register(deprecatedEndpoints);
|
||||
void fastify.register(statusRoute);
|
||||
void fastify.register(unsubscribeDeprecated);
|
||||
void fastify.register(redirectWithMessage);
|
||||
|
||||
return fastify;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
|
||||
import { type Token, createAccessToken } from '../utils/tokens';
|
||||
import cookies, { sign as signCookie, unsign as unsignCookie } from './cookies';
|
||||
import codeFlowAuth from './code-flow-auth';
|
||||
import redirectWithMessage, { formatMessage } from './redirect-with-message';
|
||||
|
||||
describe('auth', () => {
|
||||
let fastify: FastifyInstance;
|
||||
@@ -12,6 +13,7 @@ describe('auth', () => {
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(redirectWithMessage);
|
||||
await fastify.register(codeFlowAuth);
|
||||
});
|
||||
|
||||
@@ -203,4 +205,124 @@ describe('auth', () => {
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeOrRedirect', () => {
|
||||
const redirectLocation = `http://localhost:8000?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`;
|
||||
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
|
||||
fastify.get('/test', () => {
|
||||
return { message: 'ok' };
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to the origin if the access token is missing', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin if the access token is not signed', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin if the access token is invalid', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
'invalid-secret'
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin if the access token has expired', async () => {
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123', -1) },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should redirect to the origin if the user is not found', async () => {
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => null } };
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(redirectLocation);
|
||||
expect(res.statusCode).toBe(302);
|
||||
});
|
||||
|
||||
it('should populate the request with the user if the token is valid', async () => {
|
||||
const fakeUser = { id: '123', username: 'test-user' };
|
||||
// @ts-expect-error prisma isn't defined, since we're not building the
|
||||
// full application here.
|
||||
fastify.prisma = { user: { findUnique: () => fakeUser } };
|
||||
fastify.get('/test-user', req => {
|
||||
expect(req.user).toEqual(fakeUser);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ accessToken: createAccessToken('123') },
|
||||
JWT_SECRET
|
||||
);
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test-user',
|
||||
cookies: {
|
||||
jwt_access_token: signCookie(token)
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { isBefore } from 'date-fns';
|
||||
import { type user } from '@prisma/client';
|
||||
|
||||
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
|
||||
import { type Token } from '../utils/tokens';
|
||||
import { type Token, isExpired } from '../utils/tokens';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
@@ -19,6 +19,7 @@ declare module 'fastify' {
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
authorizeOrRedirect: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,41 +41,70 @@ const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
const TOKEN_INVALID = 'Your access token is invalid';
|
||||
const TOKEN_EXPIRED = 'Access token is no longer valid';
|
||||
|
||||
const send401 = (reply: FastifyReply, message: string) =>
|
||||
reply.status(401).send({ type: 'info', message });
|
||||
const send401 = (
|
||||
_req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: string
|
||||
): void => {
|
||||
void reply.status(401).send({ type: 'info', message });
|
||||
};
|
||||
|
||||
fastify.decorate(
|
||||
'authorize',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
const redirectHome = (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
_ignored: string
|
||||
) => {
|
||||
const { origin } = getRedirectParams(req);
|
||||
|
||||
void reply.redirectWithMessage(origin, {
|
||||
type: 'info',
|
||||
content:
|
||||
'Only authenticated users can access this route. Please sign in and try again.'
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuth =
|
||||
(
|
||||
rejectStrategy: (
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
message: string
|
||||
) => void
|
||||
) =>
|
||||
async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const tokenCookie = req.cookies.jwt_access_token;
|
||||
if (!tokenCookie) return send401(reply, TOKEN_REQUIRED);
|
||||
if (!tokenCookie) return rejectStrategy(req, reply, TOKEN_REQUIRED);
|
||||
|
||||
const unsignedToken = req.unsignCookie(tokenCookie);
|
||||
if (!unsignedToken.valid) return send401(reply, TOKEN_REQUIRED);
|
||||
if (!unsignedToken.valid)
|
||||
return rejectStrategy(req, reply, TOKEN_REQUIRED);
|
||||
|
||||
const jwtAccessToken = unsignedToken.value;
|
||||
|
||||
try {
|
||||
jwt.verify(jwtAccessToken!, JWT_SECRET);
|
||||
} catch {
|
||||
return send401(reply, TOKEN_INVALID);
|
||||
return rejectStrategy(req, reply, TOKEN_INVALID);
|
||||
}
|
||||
|
||||
const {
|
||||
accessToken: { created, ttl, userId }
|
||||
} = jwt.decode(jwtAccessToken!) as { accessToken: Token };
|
||||
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
|
||||
if (!valid) return send401(reply, TOKEN_EXPIRED);
|
||||
const { accessToken } = jwt.decode(jwtAccessToken!) as {
|
||||
accessToken: Token;
|
||||
};
|
||||
|
||||
if (isExpired(accessToken))
|
||||
return rejectStrategy(req, reply, TOKEN_EXPIRED);
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
where: { id: accessToken.userId }
|
||||
});
|
||||
if (!user) return send401(reply, TOKEN_INVALID);
|
||||
if (!user) return rejectStrategy(req, reply, TOKEN_INVALID);
|
||||
req.user = user;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
fastify.decorate('authorize', handleAuth(send401));
|
||||
fastify.decorate('authorizeOrRedirect', handleAuth(redirectHome));
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(codeFlowAuth);
|
||||
export default fp(codeFlowAuth, { dependencies: ['redirect-with-message'] });
|
||||
|
||||
@@ -32,15 +32,22 @@ function redirectWithMessage(
|
||||
url: string,
|
||||
message: Message
|
||||
) {
|
||||
return this.redirect(
|
||||
`${url}?${qs.stringify(
|
||||
{
|
||||
messages: qs.stringify(prepareMessage(message), {
|
||||
arrayFormat: 'index'
|
||||
})
|
||||
},
|
||||
{ arrayFormat: 'index' }
|
||||
)}`
|
||||
return this.redirect(`${url}?${formatMessage(message)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the message into a querystring.
|
||||
* @param message The message to format.
|
||||
* @returns The formatted message string.
|
||||
*/
|
||||
export function formatMessage(message: Message): string {
|
||||
return qs.stringify(
|
||||
{
|
||||
messages: qs.stringify(prepareMessage(message), {
|
||||
arrayFormat: 'index'
|
||||
})
|
||||
},
|
||||
{ arrayFormat: 'index' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,4 +57,4 @@ const plugin: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(plugin);
|
||||
export default fp(plugin, { name: 'redirect-with-message' });
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
import {
|
||||
devLogin,
|
||||
setupServer,
|
||||
superRequest,
|
||||
createSuperRequest,
|
||||
defaultUserId
|
||||
defaultUserId,
|
||||
defaultUserEmail
|
||||
} from '../../jest.utils';
|
||||
import { formatMessage } from '../plugins/redirect-with-message';
|
||||
import { createUserInput } from '../utils/create-user';
|
||||
import { API_LOCATION } from '../utils/env';
|
||||
import { API_LOCATION, HOME_LOCATION } from '../utils/env';
|
||||
import { isPictureWithProtocol, getWaitMessage } from './settings';
|
||||
|
||||
const baseProfileUI = {
|
||||
@@ -98,11 +101,13 @@ describe('settingRoutes', () => {
|
||||
|
||||
describe('Authenticated user', () => {
|
||||
let superPut: ReturnType<typeof createSuperRequest>;
|
||||
let superGet: ReturnType<typeof createSuperRequest>;
|
||||
|
||||
// Authenticate user
|
||||
beforeAll(async () => {
|
||||
const setCookies = await devLogin();
|
||||
superPut = createSuperRequest({ method: 'PUT', setCookies });
|
||||
superGet = createSuperRequest({ method: 'GET', setCookies });
|
||||
// This is not strictly necessary, since the defaultUser has this
|
||||
// profileUI, but we're interested in how the profileUI is updated. As
|
||||
// such, setting this explicitly isolates these tests.
|
||||
@@ -122,6 +127,221 @@ describe('settingRoutes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('/confirm-email', () => {
|
||||
const defaultErrorMessage = {
|
||||
type: 'danger',
|
||||
content:
|
||||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
|
||||
} as const;
|
||||
|
||||
const successMessage = {
|
||||
type: 'success',
|
||||
content: 'flash.email-valid'
|
||||
} as const;
|
||||
|
||||
const validToken =
|
||||
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGy';
|
||||
// This is a valid id for a token, but it doesn't exist in the database
|
||||
const validButMissingToken =
|
||||
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGY';
|
||||
const tokenWithMissingUser =
|
||||
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGH';
|
||||
const expiredToken =
|
||||
'4kZFEVHChxzY7kX1XSzB4uhh8fcUwcqAGWV9hv25hsI6nviVlwzXCv2YE9lENYGE';
|
||||
|
||||
const tokens = [validToken, tokenWithMissingUser, expiredToken];
|
||||
const newEmail = 'anything@goes.com';
|
||||
const encodedEmail = Buffer.from(newEmail).toString('base64');
|
||||
const notEmail = Buffer.from('foobar.com').toString('base64');
|
||||
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.authToken.create({
|
||||
data: {
|
||||
created: new Date(),
|
||||
id: validToken,
|
||||
ttl: 1000,
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.authToken.create({
|
||||
data: {
|
||||
created: new Date(),
|
||||
id: tokenWithMissingUser,
|
||||
ttl: 1000,
|
||||
// Random ObjectId
|
||||
userId: '6650ac23ccc46c0349a86dee'
|
||||
}
|
||||
});
|
||||
|
||||
await fastifyTestInstance.prisma.authToken.create({
|
||||
data: {
|
||||
created: new Date(Date.now() - 1000),
|
||||
id: expiredToken,
|
||||
ttl: 1000,
|
||||
userId: defaultUserId
|
||||
}
|
||||
});
|
||||
|
||||
// We expect these properties to be changed by the endpoint, so they
|
||||
// need to be set so that change can be confirmed.
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: {
|
||||
newEmail,
|
||||
emailVerified: false,
|
||||
emailVerifyTTL: new Date(),
|
||||
emailAuthLinkTTL: new Date()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastifyTestInstance.prisma.authToken.deleteMany({
|
||||
where: { id: { in: tokens } }
|
||||
});
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { newEmail: null, email: defaultUserEmail, emailVerified: true }
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject requests without params', async () => {
|
||||
const resNoParams = await superGet('/confirm-email');
|
||||
|
||||
expect(resNoParams.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(resNoParams.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should reject requests which have an invalid token param', async () => {
|
||||
const res = await superGet(
|
||||
// token should be 64 characters long
|
||||
`/confirm-email?email=${encodedEmail}&token=tooshort`
|
||||
);
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should reject requests which have an invalid email param', async () => {
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${notEmail}&token=${validToken}`
|
||||
);
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should reject requests when the auth token is not in the database', async () => {
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validButMissingToken}`
|
||||
);
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should reject requests when the auth token exists, but the user does not', async () => {
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validButMissingToken}`
|
||||
);
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
// TODO(Post-MVP): there's no need to keep the auth token around if,
|
||||
// somehow, the user is missing
|
||||
it.todo(
|
||||
'should delete the auth token if there is no user associated with it'
|
||||
);
|
||||
|
||||
it('should reject requests when the email param is different from user.newEmail', async () => {
|
||||
await fastifyTestInstance.prisma.user.update({
|
||||
where: { id: defaultUserId },
|
||||
data: { newEmail: 'an@oth.er' }
|
||||
});
|
||||
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validToken}`
|
||||
);
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(defaultErrorMessage)
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should reject requests if the auth token has expired', async () => {
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${expiredToken}`
|
||||
);
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` +
|
||||
formatMessage({
|
||||
content:
|
||||
'The link to confirm your new email address has expired. Please try again.',
|
||||
type: 'info'
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should update the user email', async () => {
|
||||
const res = await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validToken}`
|
||||
);
|
||||
const user = await fastifyTestInstance.prisma.user.findUniqueOrThrow({
|
||||
where: { id: defaultUserId }
|
||||
});
|
||||
|
||||
expect(res.headers.location).toBe(
|
||||
`${HOME_LOCATION}?` + formatMessage(successMessage)
|
||||
);
|
||||
expect(user.email).toBe(newEmail);
|
||||
});
|
||||
|
||||
it('should clean up the user record', async () => {
|
||||
await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validToken}`
|
||||
);
|
||||
|
||||
const user = await fastifyTestInstance.prisma.user.findUniqueOrThrow({
|
||||
where: { id: defaultUserId }
|
||||
});
|
||||
|
||||
expect(user.newEmail).toBeNull();
|
||||
expect(user.emailVerified).toBe(true);
|
||||
expect(user.emailVerifyTTL).toBeNull();
|
||||
expect(user.emailAuthLinkTTL).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove the auth token on success', async () => {
|
||||
await superGet(
|
||||
`/confirm-email?email=${encodedEmail}&token=${validToken}`
|
||||
);
|
||||
|
||||
const authToken = await fastifyTestInstance.prisma.authToken.findUnique(
|
||||
{
|
||||
where: { id: validToken }
|
||||
}
|
||||
);
|
||||
|
||||
expect(authToken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/update-my-profileui', () => {
|
||||
test('PUT returns 200 status code with "success" message', async () => {
|
||||
const response = await superPut('/update-my-profileui').send({
|
||||
@@ -799,37 +1019,51 @@ Happy coding!
|
||||
expect(user?.isClassroomAccount).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unauthenticated User', () => {
|
||||
let setCookies: string[];
|
||||
describe('Unauthenticated User', () => {
|
||||
let setCookies: string[];
|
||||
|
||||
// Get the CSRF cookies from an unprotected route
|
||||
beforeAll(async () => {
|
||||
const res = await superRequest('/status/ping', { method: 'GET' });
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
// Get the CSRF cookies from an unprotected route
|
||||
beforeAll(async () => {
|
||||
const res = await superRequest('/status/ping', { method: 'GET' });
|
||||
setCookies = res.get('Set-Cookie');
|
||||
});
|
||||
|
||||
const endpoints: { path: string; method: 'PUT' }[] = [
|
||||
{ path: '/update-my-profileui', method: 'PUT' },
|
||||
{ path: '/update-my-theme', method: 'PUT' },
|
||||
{ path: '/update-my-username', method: 'PUT' },
|
||||
{ path: '/update-my-keyboard-shortcuts', method: 'PUT' },
|
||||
{ path: '/update-my-socials', method: 'PUT' },
|
||||
{ path: '/update-my-quincy-email', method: 'PUT' },
|
||||
{ path: '/update-my-about', method: 'PUT' },
|
||||
{ path: '/update-my-honesty', method: 'PUT' },
|
||||
{ path: '/update-privacy-terms', method: 'PUT' },
|
||||
{ path: '/update-my-portfolio', method: 'PUT' }
|
||||
];
|
||||
|
||||
endpoints.forEach(({ path, method }) => {
|
||||
test(`${method} ${path} returns 401 status code with error message`, async () => {
|
||||
const response = await superRequest(path, {
|
||||
method,
|
||||
setCookies
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
describe('/confirm-email', () => {
|
||||
it('redirects to the HOME_LOCATION with flash message', async () => {
|
||||
const res = await superRequest('/confirm-email', {
|
||||
method: 'GET',
|
||||
headers: { referer: 'https://who.knows/' }
|
||||
});
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers).toMatchObject({
|
||||
location: `http://localhost:8000?${formatMessage({ type: 'info', content: 'Only authenticated users can access this route. Please sign in and try again.' })}`
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const endpoints: { path: string; method: 'PUT' }[] = [
|
||||
{ path: '/update-my-profileui', method: 'PUT' },
|
||||
{ path: '/update-my-theme', method: 'PUT' },
|
||||
{ path: '/update-my-username', method: 'PUT' },
|
||||
{ path: '/update-my-keyboard-shortcuts', method: 'PUT' },
|
||||
{ path: '/update-my-socials', method: 'PUT' },
|
||||
{ path: '/update-my-quincy-email', method: 'PUT' },
|
||||
{ path: '/update-my-about', method: 'PUT' },
|
||||
{ path: '/update-my-honesty', method: 'PUT' },
|
||||
{ path: '/update-privacy-terms', method: 'PUT' },
|
||||
{ path: '/update-my-portfolio', method: 'PUT' }
|
||||
];
|
||||
|
||||
endpoints.forEach(({ path, method }) => {
|
||||
test(`${method} ${path} returns 401 status code with error message`, async () => {
|
||||
const response = await superRequest(path, {
|
||||
method,
|
||||
setCookies
|
||||
});
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import type {
|
||||
ContextConfigDefault,
|
||||
FastifyError,
|
||||
FastifyInstance,
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
RawReplyDefaultExpression,
|
||||
@@ -14,13 +15,17 @@ import type {
|
||||
} from 'fastify';
|
||||
import { ResolveFastifyReplyType } from 'fastify/types/type-provider';
|
||||
import { differenceInMinutes } from 'date-fns';
|
||||
import validator from 'validator';
|
||||
|
||||
import { isValidUsername } from '../../../shared/utils/validate';
|
||||
import * as schemas from '../schemas';
|
||||
import { createAuthToken } from '../utils/tokens';
|
||||
import { createAuthToken, isExpired } from '../utils/tokens';
|
||||
import { API_LOCATION } from '../utils/env';
|
||||
import { getRedirectParams } from '../utils/redirection';
|
||||
import { isRestricted } from './helpers/is-restricted';
|
||||
|
||||
const { isEmail } = validator;
|
||||
|
||||
type WaitMesssageArgs = {
|
||||
sentAt: Date | null;
|
||||
now?: Date;
|
||||
@@ -439,6 +444,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
fastify.put(
|
||||
'/update-my-about',
|
||||
{
|
||||
@@ -662,3 +668,117 @@ ${isLinkSentWithinLimitTTL}`
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin for endpoints that redirect if the user is not authenticated.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options for the plugin.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const settingRedirectRoutes: FastifyPluginCallbackTypebox = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.addHook('onRequest', fastify.authorizeOrRedirect);
|
||||
|
||||
const redirectMessage = {
|
||||
type: 'danger',
|
||||
content:
|
||||
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
|
||||
} as const;
|
||||
|
||||
const expirationMessage = {
|
||||
type: 'info',
|
||||
content:
|
||||
'The link to confirm your new email address has expired. Please try again.'
|
||||
} as const;
|
||||
|
||||
const successMessage = {
|
||||
type: 'success',
|
||||
content: 'flash.email-valid'
|
||||
} as const;
|
||||
|
||||
async function updateEmail(
|
||||
fastify: FastifyInstance,
|
||||
{ id, email }: { id: string; email: string }
|
||||
) {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
email,
|
||||
emailAuthLinkTTL: null,
|
||||
emailVerified: true,
|
||||
emailVerifyTTL: null,
|
||||
newEmail: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAuthToken(
|
||||
fastify: FastifyInstance,
|
||||
{ id }: { id: string }
|
||||
) {
|
||||
await fastify.prisma.authToken.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
|
||||
fastify.get(
|
||||
'/confirm-email',
|
||||
{
|
||||
schema: schemas.confirmEmail,
|
||||
errorHandler(error, request, reply) {
|
||||
if (error.validation) {
|
||||
const { origin } = getRedirectParams(request);
|
||||
void reply.redirectWithMessage(origin, redirectMessage);
|
||||
} else {
|
||||
fastify.errorHandler(error, request, reply);
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const email = Buffer.from(req.query.email, 'base64').toString();
|
||||
|
||||
const { origin } = getRedirectParams(req);
|
||||
if (!isEmail(email)) {
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
const authToken = await fastify.prisma.authToken.findUnique({
|
||||
where: { id: req.query.token }
|
||||
});
|
||||
|
||||
if (!authToken) {
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
// TODO(Post-MVP): clean up expired auth tokens.
|
||||
if (isExpired(authToken)) {
|
||||
return reply.redirectWithMessage(origin, expirationMessage);
|
||||
}
|
||||
|
||||
// TODO(Post-MVP): should this fail if it's not the currently signed in
|
||||
// user?
|
||||
const targetUser = await fastify.prisma.user.findUnique({
|
||||
where: { id: authToken.userId }
|
||||
});
|
||||
|
||||
if (targetUser?.newEmail !== email) {
|
||||
return reply.redirectWithMessage(origin, redirectMessage);
|
||||
}
|
||||
|
||||
// TODO(Post-MVP): clean up any other auth tokens for this user once
|
||||
// the email is confirmed.
|
||||
await Promise.all([
|
||||
updateEmail(fastify, { id: targetUser.id, email }),
|
||||
deleteAuthToken(fastify, { id: authToken.id })
|
||||
]);
|
||||
|
||||
return reply.redirectWithMessage(origin, successMessage);
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export { chargeStripeCard } from './schemas/donate/charge-stripe-card';
|
||||
export { resubscribe } from './schemas/email-subscription/resubscribe';
|
||||
export { unsubscribe } from './schemas/email-subscription/unsubscribe';
|
||||
export { updateMyAbout } from './schemas/settings/update-my-about';
|
||||
export { confirmEmail } from './schemas/settings/confirm-email';
|
||||
export { updateMyClassroomMode } from './schemas/settings/update-my-classroom-mode';
|
||||
export { updateMyEmail } from './schemas/settings/update-my-email';
|
||||
export { updateMyHonesty } from './schemas/settings/update-my-honesty';
|
||||
|
||||
8
api/src/schemas/settings/confirm-email.ts
Normal file
8
api/src/schemas/settings/confirm-email.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Type } from '@fastify/type-provider-typebox';
|
||||
|
||||
export const confirmEmail = {
|
||||
querystring: Type.Object({
|
||||
email: Type.String({ maxLength: 1000 }),
|
||||
token: Type.String({ minLength: 64, maxLength: 64 })
|
||||
})
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
jest.useFakeTimers();
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { createAccessToken, createAuthToken } from './tokens';
|
||||
import { createAccessToken, createAuthToken, isExpired } from './tokens';
|
||||
|
||||
describe('createAccessToken', () => {
|
||||
it('creates an object with id, ttl, created and userId', () => {
|
||||
@@ -52,3 +53,25 @@ describe('createAuthToken', () => {
|
||||
expect(createAuthToken(userId).ttl).toBe(900000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExpired', () => {
|
||||
it('returns true if the token expiry date is in the past', () => {
|
||||
const token = createAccessToken('abc', 1000);
|
||||
expect(isExpired(token)).toBe(false);
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(isExpired(token)).toBe(false);
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(isExpired(token)).toBe(false);
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(isExpired(token)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles tokens with Date values for created', () => {
|
||||
const token = { ...createAccessToken('abc', 2000), created: new Date() };
|
||||
expect(isExpired(token)).toBe(false);
|
||||
jest.advanceTimersByTime(2000);
|
||||
expect(isExpired(token)).toBe(false);
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(isExpired(token)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,13 @@ export type Token = {
|
||||
created: string;
|
||||
};
|
||||
|
||||
type DbToken = {
|
||||
userId: string;
|
||||
id: string;
|
||||
ttl: number;
|
||||
created: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an access token.
|
||||
* @param userId The user ID as a string (yes, it's an ObjectID, but it will be serialized to a string anyway).
|
||||
@@ -49,3 +56,13 @@ export const createAuthToken = (userId: string, ttl?: number): Token => {
|
||||
created: new Date().toISOString()
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an access token has expired.
|
||||
* @param token The access token to check.
|
||||
* @returns True if the token has expired, false otherwise.
|
||||
*/
|
||||
export const isExpired = (token: Token | DbToken): boolean => {
|
||||
const created = new Date(token.created);
|
||||
return Date.now() > created.getTime() + token.ttl;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user