mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-16 07:00:53 -04:00
feat(api): use jwt_access_token (in development) (#53997)
Co-authored-by: Tom <20648924+moT01@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
bb2efe7618
commit
aacfb281fb
@@ -12,8 +12,8 @@ declare global {
|
||||
}
|
||||
|
||||
type Options = {
|
||||
sendCSRFToken: boolean;
|
||||
};
|
||||
sendCSRFToken?: boolean;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
const requests = {
|
||||
GET: (resource: string) => request(fastifyTestInstance?.server).get(resource),
|
||||
@@ -187,8 +187,8 @@ export async function devLogin(): Promise<string[]> {
|
||||
id: defaultUserId
|
||||
}
|
||||
});
|
||||
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
|
||||
expect(res.status).toBe(200);
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
expect(res.status).toBe(302);
|
||||
return res.get('Set-Cookie');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyCsrfProtection from '@fastify/csrf-protection';
|
||||
import express from '@fastify/express';
|
||||
import fastifySession from '@fastify/session';
|
||||
@@ -20,6 +19,7 @@ import Fastify, {
|
||||
} from 'fastify';
|
||||
|
||||
import prismaPlugin from './db/prisma';
|
||||
import cookies from './plugins/cookies';
|
||||
import cors from './plugins/cors';
|
||||
import { NodemailerProvider } from './plugins/mail-providers/nodemailer';
|
||||
import { SESProvider } from './plugins/mail-providers/ses';
|
||||
@@ -27,11 +27,9 @@ import mailer from './plugins/mailer';
|
||||
import redirectWithMessage from './plugins/redirect-with-message';
|
||||
import security from './plugins/security';
|
||||
import sessionAuth from './plugins/session-auth';
|
||||
import {
|
||||
devLoginCallback,
|
||||
devLegacyAuthRoutes,
|
||||
mobileAuth0Routes
|
||||
} from './routes/auth';
|
||||
import codeFlowAuth from './plugins/code-flow-auth';
|
||||
import { mobileAuth0Routes } from './routes/auth';
|
||||
import { devAuthRoutes } from './routes/auth-dev';
|
||||
import { challengeRoutes } from './routes/challenge';
|
||||
import { deprecatedEndpoints } from './routes/deprecated-endpoints';
|
||||
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
|
||||
@@ -108,7 +106,7 @@ export const build = async (
|
||||
}
|
||||
|
||||
await fastify.register(cors);
|
||||
await fastify.register(fastifyCookie);
|
||||
await fastify.register(cookies);
|
||||
|
||||
void fastify.register(fastifyCsrfProtection, {
|
||||
// TODO: consider signing cookies. We don't on the api-server, but we could
|
||||
@@ -119,17 +117,21 @@ export const build = async (
|
||||
getToken: req => req.headers['csrf-token'] as string
|
||||
});
|
||||
|
||||
// All routes should add a CSRF token to the response
|
||||
// All routes except signout should add a CSRF token to the response
|
||||
fastify.addHook('onRequest', (_req, reply, done) => {
|
||||
const token = reply.generateCsrf();
|
||||
// Path is necessary to ensure that only one cookie is set and it is valid
|
||||
// for all routes.
|
||||
void reply.setCookie('csrf_token', token, {
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
domain: COOKIE_DOMAIN,
|
||||
secure: FREECODECAMP_NODE_ENV === 'production'
|
||||
});
|
||||
const isSignout = _req.url === '/signout' || _req.url === '/signout/';
|
||||
|
||||
if (!isSignout) {
|
||||
const token = reply.generateCsrf();
|
||||
// Path is necessary to ensure that only one cookie is set and it is valid
|
||||
// for all routes.
|
||||
void reply.setCookie('csrf_token', token, {
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
domain: COOKIE_DOMAIN,
|
||||
secure: FREECODECAMP_NODE_ENV === 'production'
|
||||
});
|
||||
}
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -191,11 +193,11 @@ export const build = async (
|
||||
}
|
||||
|
||||
void fastify.register(sessionAuth);
|
||||
void fastify.register(codeFlowAuth);
|
||||
void fastify.register(prismaPlugin);
|
||||
void fastify.register(mobileAuth0Routes);
|
||||
if (FCC_ENABLE_DEV_LOGIN_MODE) {
|
||||
void fastify.register(devLoginCallback, { prefix: '/auth' });
|
||||
void fastify.register(devLegacyAuthRoutes);
|
||||
void fastify.register(devAuthRoutes);
|
||||
}
|
||||
void fastify.register(certificateRoutes);
|
||||
void fastify.register(challengeRoutes);
|
||||
|
||||
205
api/src/plugins/code-flow-auth.test.ts
Normal file
205
api/src/plugins/code-flow-auth.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import Fastify, { FastifyInstance } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { COOKIE_DOMAIN, JWT_SECRET } from '../utils/env';
|
||||
import { createAccessToken } from '../utils/tokens';
|
||||
import cookies, { sign as signCookie } from './cookies';
|
||||
import codeFlowAuth from './code-flow-auth';
|
||||
|
||||
describe('auth', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
await fastify.register(codeFlowAuth);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
describe('setAccessTokenCookie', () => {
|
||||
// We won't need to keep doubly signing the cookie when we migrate the
|
||||
// authentication, but for the MVP we have to be able to read the cookies
|
||||
// set by the api-server. So, double signing:
|
||||
it('should doubly sign the cookie', async () => {
|
||||
const token = createAccessToken('test-id');
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
reply.setAccessTokenCookie(token);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const singlySignedToken = jwt.sign({ accessToken: token }, JWT_SECRET);
|
||||
const doublySignedToken = signCookie(singlySignedToken);
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.cookies[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'jwt_access_token',
|
||||
value: doublySignedToken,
|
||||
path: '/',
|
||||
sameSite: 'Lax',
|
||||
domain: COOKIE_DOMAIN
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: Post-MVP sync the cookie max-age with the token ttl (i.e. the
|
||||
// max-age should be the ttl/1000, not ttl)
|
||||
it('should set the max-age of the cookie to match the ttl of the token', async () => {
|
||||
const token = createAccessToken('test-id', 123000);
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
reply.setAccessTokenCookie(token);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.cookies[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
maxAge: 123000
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorize', () => {
|
||||
beforeEach(() => {
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
fastify.get('/test', () => {
|
||||
return { message: 'ok' };
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject if the access token is missing', async () => {
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
type: 'info',
|
||||
message: 'Access token is required for this request'
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject 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.json()).toEqual({
|
||||
type: 'info',
|
||||
message: 'Access token is required for this request'
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject 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.json()).toEqual({
|
||||
type: 'info',
|
||||
message: 'Your access token is invalid'
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject 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.json()).toEqual({
|
||||
type: 'info',
|
||||
message: 'Access token is no longer valid'
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject 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.json()).toEqual({
|
||||
type: 'info',
|
||||
message: 'Your access token is invalid'
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
86
api/src/plugins/code-flow-auth.ts
Normal file
86
api/src/plugins/code-flow-auth.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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 { AccessToken } from '../utils/tokens';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyReply {
|
||||
setAccessTokenCookie: (
|
||||
this: FastifyReply,
|
||||
accessToken: AccessToken
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
// TODO: is the full user the correct type here?
|
||||
user?: user;
|
||||
}
|
||||
|
||||
interface FastifyInstance {
|
||||
authorize: (req: FastifyRequest, reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
const codeFlowAuth: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.decorateReply(
|
||||
'setAccessTokenCookie',
|
||||
function (accessToken: AccessToken) {
|
||||
const signedToken = jwt.sign({ accessToken }, JWT_SECRET);
|
||||
void this.setCookie('jwt_access_token', signedToken, {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: 'lax',
|
||||
domain: COOKIE_DOMAIN,
|
||||
signed: true,
|
||||
maxAge: accessToken.ttl
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const TOKEN_REQUIRED = 'Access token is required for this request';
|
||||
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 });
|
||||
|
||||
fastify.decorate(
|
||||
'authorize',
|
||||
async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
const tokenCookie = req.cookies.jwt_access_token;
|
||||
if (!tokenCookie) return send401(reply, TOKEN_REQUIRED);
|
||||
|
||||
const unsignedToken = req.unsignCookie(tokenCookie);
|
||||
if (!unsignedToken.valid) return send401(reply, TOKEN_REQUIRED);
|
||||
|
||||
const jwtAccessToken = unsignedToken.value;
|
||||
|
||||
try {
|
||||
jwt.verify(jwtAccessToken!, JWT_SECRET);
|
||||
} catch {
|
||||
return send401(reply, TOKEN_INVALID);
|
||||
}
|
||||
|
||||
const {
|
||||
accessToken: { created, ttl, userId }
|
||||
} = jwt.decode(jwtAccessToken!) as { accessToken: AccessToken };
|
||||
const valid = isBefore(Date.now(), Date.parse(created) + ttl);
|
||||
if (!valid) return send401(reply, TOKEN_EXPIRED);
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
if (!user) return send401(reply, TOKEN_INVALID);
|
||||
req.user = user;
|
||||
}
|
||||
);
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(codeFlowAuth);
|
||||
70
api/src/plugins/cookies.test.ts
Normal file
70
api/src/plugins/cookies.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
|
||||
import { COOKIE_SECRET } from '../utils/env';
|
||||
import cookies from './cookies';
|
||||
|
||||
describe('cookies', () => {
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
fastify = Fastify();
|
||||
await fastify.register(cookies);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fastify.close();
|
||||
});
|
||||
|
||||
it('should prefix signed cookies with "s:" (url-encoded)', async () => {
|
||||
fastify.get('/test', async (req, reply) => {
|
||||
void reply.setCookie('test', 'value', { signed: true });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test'
|
||||
});
|
||||
|
||||
expect(res.headers['set-cookie']).toMatch(/test=s%3Avalue\.\w*/);
|
||||
});
|
||||
|
||||
it('should be able to unsign cookies', async () => {
|
||||
const signedCookie = `test=s%3A${fastifyCookie.sign('value', COOKIE_SECRET)}`;
|
||||
fastify.get('/test', (req, reply) => {
|
||||
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
cookie: signedCookie
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
unsigned: { value: 'value', renew: false, valid: true }
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject cookies not prefixed with "s:"', async () => {
|
||||
const signedCookie = `test=${fastifyCookie.sign('value', COOKIE_SECRET)}`;
|
||||
fastify.get('/test', (req, reply) => {
|
||||
void reply.send({ unsigned: req.unsignCookie(req.cookies.test!) });
|
||||
});
|
||||
|
||||
const res = await fastify.inject({
|
||||
method: 'GET',
|
||||
url: '/test',
|
||||
headers: {
|
||||
cookie: signedCookie
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.json()).toEqual({
|
||||
unsigned: { value: null, renew: false, valid: false }
|
||||
});
|
||||
});
|
||||
});
|
||||
50
api/src/plugins/cookies.ts
Normal file
50
api/src/plugins/cookies.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { COOKIE_SECRET } from '../utils/env';
|
||||
|
||||
/**
|
||||
* Signs a cookie value by prefixing it with "s:" and using the COOKIE_SECRET.
|
||||
*
|
||||
* @param value The value to sign.
|
||||
* @returns The signed cookie value.
|
||||
*/
|
||||
export const sign = (value: string) =>
|
||||
's:' + fastifyCookie.sign(value, COOKIE_SECRET);
|
||||
|
||||
/**
|
||||
* Unsigns a cookie value by removing the "s:" prefix and using the COOKIE_SECRET.
|
||||
*
|
||||
* @param rawValue The signed cookie value.
|
||||
* @returns The unsigned cookie value.
|
||||
*/
|
||||
export const unsign = (rawValue: string) => {
|
||||
const prefix = rawValue.slice(0, 2);
|
||||
if (prefix !== 's:') return { valid: false, renew: false, value: null };
|
||||
|
||||
const value = rawValue.slice(2);
|
||||
return fastifyCookie.unsign(value, COOKIE_SECRET);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compatibility plugin for using cookies signed by express. By prefixing with
|
||||
* "s:" and removing it when unsigning, we can continue to use the same cookies
|
||||
* in Fastify.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
const cookies: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
void fastify.register(fastifyCookie, {
|
||||
secret: {
|
||||
sign,
|
||||
unsign
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
export default fp(cookies);
|
||||
@@ -2,15 +2,7 @@ import { FastifyPluginCallback } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
|
||||
import { HOME_LOCATION } from '../utils/env';
|
||||
|
||||
const allowedOrigins = [
|
||||
'https://www.freecodecamp.dev',
|
||||
'https://www.freecodecamp.org',
|
||||
'https://beta.freecodecamp.dev',
|
||||
'https://beta.freecodecamp.org',
|
||||
'https://chinese.freecodecamp.dev',
|
||||
'https://chinese.freecodecamp.org'
|
||||
];
|
||||
import { allowedOrigins } from '../utils/allowed-origins';
|
||||
|
||||
const cors: FastifyPluginCallback = (fastify, _options, done) => {
|
||||
fastify.options('*', (_req, reply) => {
|
||||
|
||||
199
api/src/routes/auth-dev.test.ts
Normal file
199
api/src/routes/auth-dev.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
defaultUserEmail,
|
||||
devLogin,
|
||||
setupServer,
|
||||
superRequest
|
||||
} from '../../jest.utils';
|
||||
import { HOME_LOCATION } from '../utils/env';
|
||||
|
||||
describe('dev login', () => {
|
||||
setupServer();
|
||||
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /signin', () => {
|
||||
it('should create an account if one does not exist', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
|
||||
const count = await fastifyTestInstance.prisma.user.count({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(res.body).toStrictEqual({});
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
|
||||
it('should populate the user with the correct data', async () => {
|
||||
const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
|
||||
await superRequest('/signin', { method: 'GET' });
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
about: '',
|
||||
acceptedPrivacyTerms: false,
|
||||
completedChallenges: [],
|
||||
currentChallengeId: '',
|
||||
email: defaultUserEmail,
|
||||
emailVerified: true,
|
||||
externalId: expect.stringMatching(uuidRe),
|
||||
is2018DataVisCert: false,
|
||||
is2018FullStackCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isBackEndCert: false,
|
||||
isBanned: false,
|
||||
isCheater: false,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isDonating: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isFullStackCert: false,
|
||||
isHonest: false,
|
||||
isInfosecCertV7: false,
|
||||
isInfosecQaCert: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
isQaCertV7: false,
|
||||
isRelationalDatabaseCertV8: false,
|
||||
isCollegeAlgebraPyCertV8: false,
|
||||
isRespWebDesignCert: false,
|
||||
isSciCompPyCertV7: false,
|
||||
keyboardShortcuts: false,
|
||||
location: '',
|
||||
name: '',
|
||||
unsubscribeId: '',
|
||||
picture: '',
|
||||
profileUI: {
|
||||
isLocked: false,
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
},
|
||||
progressTimestamps: [],
|
||||
sendQuincyEmail: false,
|
||||
theme: 'default',
|
||||
username: expect.stringMatching(fccUuidRe),
|
||||
usernameDisplay: expect.stringMatching(fccUuidRe)
|
||||
});
|
||||
expect(user.username).toBe(user.usernameDisplay);
|
||||
});
|
||||
|
||||
it('should set the jwt_access_token cookie', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers['set-cookie']).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/jwt_access_token=/)])
|
||||
);
|
||||
// TODO: check the cookie value
|
||||
});
|
||||
|
||||
it.todo('should create a session');
|
||||
|
||||
// duplicate of the server.test test to make sure I've not done something
|
||||
// silly
|
||||
it('should have OWASP recommended headers', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
expect(res.headers).toMatchObject({
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy': "frame-ancestors 'none'",
|
||||
'x-content-type-options': 'nosniff',
|
||||
'x-frame-options': 'DENY'
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to the Referer (if it is a valid origin)', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' }).set(
|
||||
'referer',
|
||||
'https://www.freecodecamp.org/some-path/or/other'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/some-path/or/other'
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect to /valid-language/learn when signing in from /valid-language', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' }).set(
|
||||
'referer',
|
||||
'https://www.freecodecamp.org/espanol'
|
||||
);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/espanol/learn'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle referers with trailing slahes', async () => {
|
||||
const res = await superRequest('/signin', {
|
||||
method: 'GET'
|
||||
}).set('referer', 'https://www.freecodecamp.org/espanol/');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(
|
||||
'https://www.freecodecamp.org/espanol/learn'
|
||||
);
|
||||
});
|
||||
|
||||
it('should redirect to /learn by default', async () => {
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /signout', () => {
|
||||
beforeEach(async () => {
|
||||
await devLogin();
|
||||
});
|
||||
it('should clear all the cookies', async () => {
|
||||
const res = await superRequest('/signout', { method: 'GET' });
|
||||
|
||||
const setCookie = res.headers['set-cookie'];
|
||||
expect(setCookie).toEqual(
|
||||
expect.arrayContaining([
|
||||
'jwt_access_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
'_csrf=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||||
'csrf_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
])
|
||||
);
|
||||
expect(setCookie).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should redirect to / on the client by default', async () => {
|
||||
const res = await superRequest('/signout', { method: 'GET' });
|
||||
|
||||
// This happens because localhost:8000 is not an allowed origin and so
|
||||
// normalizeParams rejects it and sets the returnTo to /learn. TODO:
|
||||
// separate the validation and normalization logic.
|
||||
expect(res.headers.location).toBe(`${HOME_LOCATION}/learn`);
|
||||
expect(res.status).toBe(302);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
api/src/routes/auth-dev.ts
Normal file
58
api/src/routes/auth-dev.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { createAccessToken } from '../utils/tokens';
|
||||
import {
|
||||
getPrefixedLandingPath,
|
||||
getRedirectParams,
|
||||
haveSamePath
|
||||
} from '../utils/redirection';
|
||||
import { findOrCreateUser } from './helpers/auth-helpers';
|
||||
|
||||
const trimTrailingSlash = (str: string) =>
|
||||
str.endsWith('/') ? str.slice(0, -1) : str;
|
||||
|
||||
/**
|
||||
* Route handler for development login.
|
||||
*
|
||||
* @deprecated
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin,
|
||||
* options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const devAuthRoutes: FastifyPluginCallback = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
async function handleRedirects(req: FastifyRequest, reply: FastifyReply) {
|
||||
const params = getRedirectParams(req);
|
||||
const { origin, pathPrefix } = params;
|
||||
const returnTo = trimTrailingSlash(params.returnTo);
|
||||
const landingUrl = getPrefixedLandingPath(origin, pathPrefix);
|
||||
|
||||
return await reply.redirect(
|
||||
haveSamePath(landingUrl, returnTo) ? `${returnTo}/learn` : returnTo
|
||||
);
|
||||
}
|
||||
|
||||
fastify.get('/signin', async (req, reply) => {
|
||||
const email = 'foo@bar.com';
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
|
||||
reply.setAccessTokenCookie(createAccessToken(id));
|
||||
|
||||
await handleRedirects(req, reply);
|
||||
});
|
||||
|
||||
fastify.get('/signout', async (req, reply) => {
|
||||
void reply.clearCookie('jwt_access_token');
|
||||
void reply.clearCookie('csrf_token');
|
||||
void reply.clearCookie('_csrf');
|
||||
|
||||
const { returnTo } = getRedirectParams(req);
|
||||
await reply.redirect(returnTo);
|
||||
});
|
||||
done();
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { defaultUserEmail, setupServer, superRequest } from '../../jest.utils';
|
||||
|
||||
describe('dev login', () => {
|
||||
setupServer();
|
||||
beforeEach(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastifyTestInstance.prisma.user.deleteMany({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
});
|
||||
|
||||
it('should create an account if one does not exist', async () => {
|
||||
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
|
||||
|
||||
const count = await fastifyTestInstance.prisma.user.count({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(count).toBe(1);
|
||||
|
||||
expect(res.body).toStrictEqual({ statusCode: 200 });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should populate the user with the correct data', async () => {
|
||||
const uuidRe = /^[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
const fccUuidRe = /^fcc-[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
||||
|
||||
await superRequest('/auth/dev-callback', { method: 'GET' });
|
||||
const user = await fastifyTestInstance.prisma.user.findFirstOrThrow({
|
||||
where: { email: defaultUserEmail }
|
||||
});
|
||||
|
||||
expect(user).toMatchObject({
|
||||
about: '',
|
||||
acceptedPrivacyTerms: false,
|
||||
completedChallenges: [],
|
||||
currentChallengeId: '',
|
||||
email: defaultUserEmail,
|
||||
emailVerified: true,
|
||||
externalId: expect.stringMatching(uuidRe),
|
||||
is2018DataVisCert: false,
|
||||
is2018FullStackCert: false,
|
||||
isApisMicroservicesCert: false,
|
||||
isBackEndCert: false,
|
||||
isBanned: false,
|
||||
isCheater: false,
|
||||
isDataAnalysisPyCertV7: false,
|
||||
isDataVisCert: false,
|
||||
isDonating: false,
|
||||
isFrontEndCert: false,
|
||||
isFrontEndLibsCert: false,
|
||||
isFullStackCert: false,
|
||||
isHonest: false,
|
||||
isInfosecCertV7: false,
|
||||
isInfosecQaCert: false,
|
||||
isJsAlgoDataStructCert: false,
|
||||
isMachineLearningPyCertV7: false,
|
||||
isQaCertV7: false,
|
||||
isRelationalDatabaseCertV8: false,
|
||||
isCollegeAlgebraPyCertV8: false,
|
||||
isRespWebDesignCert: false,
|
||||
isSciCompPyCertV7: false,
|
||||
keyboardShortcuts: false,
|
||||
location: '',
|
||||
name: '',
|
||||
unsubscribeId: '',
|
||||
picture: '',
|
||||
profileUI: {
|
||||
isLocked: false,
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
},
|
||||
progressTimestamps: [],
|
||||
sendQuincyEmail: false,
|
||||
theme: 'default',
|
||||
username: expect.stringMatching(fccUuidRe),
|
||||
usernameDisplay: expect.stringMatching(fccUuidRe)
|
||||
});
|
||||
expect(user.username).toBe(user.usernameDisplay);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
FastifyInstance,
|
||||
FastifyPluginCallback,
|
||||
FastifyRequest
|
||||
} from 'fastify';
|
||||
import { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
import rateLimit from 'express-rate-limit';
|
||||
// @ts-expect-error - no types
|
||||
import MongoStoreRL from 'rate-limit-mongo';
|
||||
|
||||
import { createUserInput } from '../utils/create-user';
|
||||
import { AUTH0_DOMAIN, HOME_LOCATION, MONGOHQ_URL } from '../utils/env';
|
||||
import { AUTH0_DOMAIN, MONGOHQ_URL } from '../utils/env';
|
||||
import { findOrCreateUser } from './helpers/auth-helpers';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface Session {
|
||||
@@ -35,49 +31,6 @@ const getEmailFromAuth0 = async (req: FastifyRequest) => {
|
||||
return email;
|
||||
};
|
||||
|
||||
const findOrCreateUser = async (fastify: FastifyInstance, email: string) => {
|
||||
// TODO: handle the case where there are multiple users with the same email.
|
||||
// e.g. use findMany and throw an error if more than one is found.
|
||||
const existingUser = await fastify.prisma.user.findFirst({
|
||||
where: { email },
|
||||
select: { id: true }
|
||||
});
|
||||
return (
|
||||
existingUser ??
|
||||
(await fastify.prisma.user.create({
|
||||
data: createUserInput(email),
|
||||
select: { id: true }
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route handler for development login. This is only used in local
|
||||
* development, and bypasses Auth0, authenticating as the development
|
||||
* user.
|
||||
*
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin, options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
// TODO: 1) use POST 2) make sure we prevent login CSRF
|
||||
export const devLoginCallback: FastifyPluginCallback = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get('/dev-callback', async req => {
|
||||
const email = 'foo@bar.com';
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
req.session.user = { id };
|
||||
await req.session.save();
|
||||
return { statusCode: 200 };
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Route handler for Mobile authentication.
|
||||
*
|
||||
@@ -120,36 +73,3 @@ export const mobileAuth0Routes: FastifyPluginCallback = (
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
/**
|
||||
* Legacy route handler for development login. This mimics the behaviour of old
|
||||
* api-server which the client depends on for authentication. The key difference
|
||||
* is that this uses a different cookie (not jwt_access_token), and, if we want
|
||||
* to use this for real, we will need to account for that.
|
||||
*
|
||||
* @deprecated
|
||||
* @param fastify The Fastify instance.
|
||||
* @param _options Options passed to the plugin via `fastify.register(plugin,
|
||||
* options)`.
|
||||
* @param done Callback to signal that the logic has completed.
|
||||
*/
|
||||
export const devLegacyAuthRoutes: FastifyPluginCallback = (
|
||||
fastify,
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.get('/signin', async (req, reply) => {
|
||||
const email = 'foo@bar.com';
|
||||
|
||||
const { id } = await findOrCreateUser(fastify, email);
|
||||
req.session.user = { id };
|
||||
await req.session.save();
|
||||
await reply.redirect(HOME_LOCATION + '/learn');
|
||||
});
|
||||
|
||||
fastify.get('/signout', async (req, reply) => {
|
||||
await req.session.destroy();
|
||||
await reply.redirect(HOME_LOCATION + '/learn');
|
||||
});
|
||||
done();
|
||||
};
|
||||
|
||||
@@ -79,7 +79,9 @@ describe('certificate routes', () => {
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test('should return 500 if user not found in db', async () => {
|
||||
// TODO: Revisit this test after deciding if we need/want to fetch the
|
||||
// entire user during authorization or just the user id.
|
||||
test.skip('should return 500 if user not found in db', async () => {
|
||||
jest
|
||||
.spyOn(fastifyTestInstance.prisma.user, 'findUnique')
|
||||
.mockImplementation(
|
||||
|
||||
@@ -57,7 +57,7 @@ export const certificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
// TODO(POST_MVP): Response should not include updated user. If a client wants the updated user, it should make a separate request
|
||||
// OR: Always respond with current user - full user object - not random pieces.
|
||||
@@ -102,7 +102,7 @@ export const certificateRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -56,7 +56,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
fastify.post(
|
||||
'/coderoad-challenge-completed',
|
||||
@@ -150,7 +150,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
};
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
partiallyCompletedChallenges: uniqBy(
|
||||
[finalChallenge, ...partiallyCompletedChallenges],
|
||||
@@ -193,7 +193,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
},
|
||||
async (req, reply) => {
|
||||
const { id: projectId, challengeType, solution, githubLink } = req.body;
|
||||
const userId = req.session.user.id;
|
||||
const userId = req.user?.id;
|
||||
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
@@ -281,7 +281,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
const progressTimestamps = user.progressTimestamps as
|
||||
| ProgressTimestamp[]
|
||||
@@ -335,7 +335,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
const { id, files, challengeType } = req.body;
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
const RawProgressTimestamp = user.progressTimestamps as
|
||||
| ProgressTimestamp[]
|
||||
@@ -399,7 +399,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
try {
|
||||
const { files, id: challengeId } = req.body;
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
const challenge = {
|
||||
id: challengeId,
|
||||
@@ -459,7 +459,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
|
||||
const { completedChallenges } =
|
||||
await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
select: { completedChallenges: true }
|
||||
});
|
||||
|
||||
@@ -550,7 +550,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
const msUser = await fastify.prisma.msUsername.findFirst({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user?.id }
|
||||
});
|
||||
|
||||
if (!msUser || !msUser.msUsername) {
|
||||
@@ -574,7 +574,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
select: { completedChallenges: true, progressTimestamps: true }
|
||||
});
|
||||
|
||||
@@ -596,7 +596,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
solution: msTrophyStatus.msUserAchievementsApiUrl
|
||||
};
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
completedChallenges: {
|
||||
push: newChallenge
|
||||
@@ -639,7 +639,7 @@ export const challengeRoutes: FastifyPluginCallbackTypebox = (
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { id: userId } = req.session.user;
|
||||
const userId = req.user?.id;
|
||||
const { userCompletedExam, id, challengeType } = req.body;
|
||||
|
||||
const { completedChallenges, completedExams, progressTimestamps } =
|
||||
|
||||
@@ -32,7 +32,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
fastify.post(
|
||||
'/donate/add-donation',
|
||||
{
|
||||
@@ -56,7 +56,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
if (user?.isDonating) {
|
||||
@@ -68,7 +68,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
isDonating: true
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export const donateRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { paymentMethodId, amount, duration } = req.body;
|
||||
const { id } = req.session.user;
|
||||
const id = req.user!.id;
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id }
|
||||
|
||||
27
api/src/routes/helpers/auth-helpers.ts
Normal file
27
api/src/routes/helpers/auth-helpers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createUserInput } from '../../utils/create-user';
|
||||
|
||||
/**
|
||||
* Finds an existing user with the given email or creates a new user if none exists.
|
||||
* @param fastify - The Fastify instance.
|
||||
* @param email - The email of the user.
|
||||
* @returns The existing or newly created user.
|
||||
*/
|
||||
export const findOrCreateUser = async (
|
||||
fastify: FastifyInstance,
|
||||
email: string
|
||||
) => {
|
||||
// TODO: handle the case where there are multiple users with the same email.
|
||||
// e.g. use findMany and throw an error if more than one is found.
|
||||
const existingUser = await fastify.prisma.user.findFirst({
|
||||
where: { email },
|
||||
select: { id: true }
|
||||
});
|
||||
return (
|
||||
existingUser ??
|
||||
(await fastify.prisma.user.create({
|
||||
data: createUserInput(email),
|
||||
select: { id: true }
|
||||
}))
|
||||
);
|
||||
};
|
||||
@@ -79,7 +79,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
|
||||
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
type CommonResponseSchema = {
|
||||
response: { 400: (typeof schemas.updateMyProfileUI.response)[400] };
|
||||
@@ -123,7 +123,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
profileUI: {
|
||||
isLocked: req.body.profileUI.isLocked,
|
||||
@@ -167,7 +167,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
|
||||
}
|
||||
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
select: {
|
||||
email: true,
|
||||
emailVerifyTTL: true,
|
||||
@@ -216,7 +216,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
// ToDo(MVP): email the new email and wait user to confirm it, before we update the user schema.
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
newEmail,
|
||||
emailVerified: false,
|
||||
@@ -239,7 +239,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
emailAuthLinkTTL: new Date()
|
||||
}
|
||||
@@ -263,7 +263,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
theme: req.body.theme
|
||||
}
|
||||
@@ -290,7 +290,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
website: req.body.website,
|
||||
twitter: req.body.twitter,
|
||||
@@ -320,7 +320,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findFirstOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
const newUsernameDisplay = req.body.username.trim();
|
||||
@@ -379,7 +379,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
}
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
username: newUsername,
|
||||
usernameDisplay: newUsernameDisplay
|
||||
@@ -408,7 +408,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
about: req.body.about,
|
||||
name: req.body.name,
|
||||
@@ -438,7 +438,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
keyboardShortcuts: req.body.keyboardShortcuts
|
||||
}
|
||||
@@ -465,7 +465,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
sendQuincyEmail: req.body.sendQuincyEmail
|
||||
}
|
||||
@@ -492,7 +492,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
isHonest: req.body.isHonest
|
||||
}
|
||||
@@ -519,7 +519,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
acceptedPrivacyTerms: true,
|
||||
sendQuincyEmail: req.body.quincyEmails
|
||||
@@ -558,7 +558,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
})
|
||||
);
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user?.id },
|
||||
data: {
|
||||
portfolio
|
||||
}
|
||||
@@ -594,7 +594,7 @@ ${isLinkSentWithinLimitTTL}`
|
||||
const classroomMode = req.body.isClassroomAccount;
|
||||
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user!.id },
|
||||
data: {
|
||||
isClassroomAccount: classroomMode
|
||||
}
|
||||
|
||||
@@ -525,6 +525,14 @@ describe('userRoutes', () => {
|
||||
expect(response.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
// This should help debugging, since this the route returns this if
|
||||
// anything throws in the handler.
|
||||
test('GET does not return the error response if the request is valid', async () => {
|
||||
const response = await superGet('/user/get-session-user');
|
||||
|
||||
expect(response.body).not.toEqual({ user: {}, result: '' });
|
||||
});
|
||||
|
||||
test('GET returns username as the result property', async () => {
|
||||
const response = await superGet('/user/get-session-user');
|
||||
|
||||
@@ -597,7 +605,7 @@ describe('userRoutes', () => {
|
||||
});
|
||||
|
||||
// devLogin must not be used here since it overrides the user
|
||||
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
|
||||
const res = await superRequest('/signin', { method: 'GET' });
|
||||
const setCookies = res.get('Set-Cookie');
|
||||
|
||||
const publicUser = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { schemas } from '../schemas';
|
||||
// Loopback creates a 64 character string for the user id, this customizes
|
||||
// nanoid to do the same. Any unique key _should_ be fine, though.
|
||||
import { customNanoid } from '../utils/ids';
|
||||
import {
|
||||
normalizeChallenges,
|
||||
normalizeProfileUI,
|
||||
@@ -15,17 +17,10 @@ import {
|
||||
getPoints,
|
||||
type ProgressTimestamp
|
||||
} from '../utils/progress';
|
||||
import { encodeUserToken } from '../utils/user-token';
|
||||
import { encodeUserToken } from '../utils/tokens';
|
||||
import { trimTags } from '../utils/validation';
|
||||
import { generateReportEmail } from '../utils/email-templates';
|
||||
|
||||
// Loopback creates a 64 character string for the user id, this customizes
|
||||
// nanoid to do the same. Any unique key _should_ be fine, though.
|
||||
const nanoid = customAlphabet(
|
||||
'1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
64
|
||||
);
|
||||
|
||||
/**
|
||||
* Helper function to get the api url from the shared transcript link.
|
||||
*
|
||||
@@ -60,7 +55,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
// @ts-expect-error - @fastify/csrf-protection needs to update their types
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
fastify.addHook('onRequest', fastify.csrfProtection);
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
fastify.post(
|
||||
'/account/delete',
|
||||
@@ -70,16 +65,16 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.msUsername.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.survey.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.user.delete({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user!.id }
|
||||
});
|
||||
await req.session.destroy();
|
||||
void reply.clearCookie('sessionId');
|
||||
@@ -105,16 +100,16 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.msUsername.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.survey.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user!.id },
|
||||
data: {
|
||||
progressTimestamps: [Date.now()],
|
||||
currentChallengeId: '',
|
||||
@@ -159,14 +154,14 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
// TODO(Post-MVP): POST -> PUT
|
||||
fastify.post('/user/user-token', async req => {
|
||||
await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user?.id }
|
||||
});
|
||||
|
||||
const token = await fastify.prisma.userToken.create({
|
||||
data: {
|
||||
created: new Date(),
|
||||
id: nanoid(),
|
||||
userId: req.session.user.id,
|
||||
id: customNanoid(),
|
||||
userId: req.user!.id,
|
||||
// TODO(Post-MVP): expire after ttl has passed.
|
||||
ttl: 77760000000 // 900 * 24 * 60 * 60 * 1000
|
||||
}
|
||||
@@ -185,7 +180,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { count } = await fastify.prisma.userToken.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user?.id }
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
@@ -220,7 +215,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
const { username, reportDescription: report } = req.body;
|
||||
|
||||
@@ -267,7 +262,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
await fastify.prisma.msUsername.deleteMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user?.id }
|
||||
});
|
||||
|
||||
// TODO(Post-MVP): return a generic success message.
|
||||
@@ -301,7 +296,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
|
||||
const msApiRes = await fetch(
|
||||
@@ -387,7 +382,7 @@ export const userRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const user = await fastify.prisma.user.findUniqueOrThrow({
|
||||
where: { id: req.session.user.id }
|
||||
where: { id: req.user?.id }
|
||||
});
|
||||
const { surveyResults } = req.body;
|
||||
const { title } = surveyResults;
|
||||
@@ -446,7 +441,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
_options,
|
||||
done
|
||||
) => {
|
||||
fastify.addHook('onRequest', fastify.authenticateSession);
|
||||
fastify.addHook('onRequest', fastify.authorize);
|
||||
|
||||
fastify.get(
|
||||
'/user/get-session-user',
|
||||
@@ -456,11 +451,11 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
async (req, res) => {
|
||||
try {
|
||||
const userTokenP = fastify.prisma.userToken.findFirst({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
|
||||
const userP = fastify.prisma.user.findUnique({
|
||||
where: { id: req.session.user.id },
|
||||
where: { id: req.user!.id },
|
||||
select: {
|
||||
about: true,
|
||||
acceptedPrivacyTerms: true,
|
||||
@@ -512,7 +507,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
|
||||
});
|
||||
|
||||
const completedSurveysP = fastify.prisma.survey.findMany({
|
||||
where: { userId: req.session.user.id }
|
||||
where: { userId: req.user!.id }
|
||||
});
|
||||
|
||||
const [userToken, user, completedSurveys] = await Promise.all([
|
||||
|
||||
@@ -55,6 +55,7 @@ assert.ok(process.env.JWT_SECRET);
|
||||
assert.ok(process.env.STRIPE_SECRET_KEY);
|
||||
assert.ok(process.env.SHOW_UPCOMING_CHANGES);
|
||||
assert.ok(process.env.MONGOHQ_URL);
|
||||
assert.ok(process.env.COOKIE_SECRET);
|
||||
|
||||
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
assert.ok(process.env.SES_ID);
|
||||
@@ -66,6 +67,7 @@ if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
|
||||
);
|
||||
assert.ok(process.env.SES_REGION);
|
||||
assert.ok(process.env.COOKIE_DOMAIN);
|
||||
assert.notEqual(process.env.COOKIE_SECRET, 'a_cookie_secret');
|
||||
assert.ok(process.env.PORT);
|
||||
assert.ok(process.env.SENTRY_DSN);
|
||||
// The following values can exist in development, but production-like
|
||||
@@ -125,6 +127,7 @@ export const SENTRY_DSN =
|
||||
? ''
|
||||
: process.env.SENTRY_DSN;
|
||||
export const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || 'localhost';
|
||||
export const COOKIE_SECRET = process.env.COOKIE_SECRET;
|
||||
export const JWT_SECRET = process.env.JWT_SECRET;
|
||||
export const SES_ID = process.env.SES_ID;
|
||||
export const SES_SECRET = process.env.SES_SECRET;
|
||||
|
||||
7
api/src/utils/ids.ts
Normal file
7
api/src/utils/ids.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
// uppercase, lowercase letters and numbers
|
||||
export const customNanoid = customAlphabet(
|
||||
'1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||
64
|
||||
);
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
// import { allowedOrigins } from '../../config/allowed-origins';
|
||||
import { availableLangs } from '../../../shared/config/i18n';
|
||||
import { allowedOrigins } from './allowed-origins';
|
||||
|
||||
|
||||
28
api/src/utils/tokens.test.ts
Normal file
28
api/src/utils/tokens.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { createAccessToken } from './tokens';
|
||||
|
||||
describe('createAccessToken', () => {
|
||||
it('creates an object with id, ttl, created and userId', () => {
|
||||
const userId = 'abc';
|
||||
|
||||
const actual = createAccessToken(userId);
|
||||
|
||||
expect(actual).toStrictEqual({
|
||||
id: expect.stringMatching(/[a-zA-Z0-9]{64}/),
|
||||
ttl: 77760000000,
|
||||
created: expect.stringMatching(
|
||||
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/
|
||||
),
|
||||
userId
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the ttl, defaulting to 77760000000 ms', () => {
|
||||
const userId = 'abc';
|
||||
const ttl = 123;
|
||||
const actual = createAccessToken(userId, ttl);
|
||||
|
||||
expect(actual.ttl).toBe(ttl);
|
||||
expect(createAccessToken(userId).ttl).toBe(77760000000);
|
||||
});
|
||||
});
|
||||
39
api/src/utils/tokens.ts
Normal file
39
api/src/utils/tokens.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { customNanoid } from './ids';
|
||||
|
||||
import { JWT_SECRET } from './env';
|
||||
|
||||
/**
|
||||
* Encode an id into a JWT (the naming suggests it's a user token, but it's the
|
||||
* id of the UserToken document).
|
||||
* @param userToken A token id to encode.
|
||||
* @returns An encoded object with the userToken property.
|
||||
*/
|
||||
export function encodeUserToken(userToken: string): string {
|
||||
return jwt.sign({ userToken }, JWT_SECRET);
|
||||
}
|
||||
|
||||
export type AccessToken = {
|
||||
userId: string;
|
||||
id: string;
|
||||
ttl: number;
|
||||
created: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* @param ttl The time to live for the token in milliseconds (default: 77760000000).
|
||||
* @returns The access token.
|
||||
*/
|
||||
export const createAccessToken = (
|
||||
userId: string,
|
||||
ttl?: number
|
||||
): AccessToken => {
|
||||
return {
|
||||
userId,
|
||||
id: customNanoid(),
|
||||
ttl: ttl ?? 77760000000,
|
||||
created: new Date().toISOString()
|
||||
};
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { JWT_SECRET } from './env';
|
||||
|
||||
/**
|
||||
* Encode an id into a JWT (the naming suggests it's a user token, but it's the
|
||||
* id of the UserToken document).
|
||||
* @param userToken A token id to encode.
|
||||
* @returns An encoded object with the userToken property.
|
||||
*/
|
||||
export function encodeUserToken(userToken: string): string {
|
||||
return jwt.sign({ userToken }, JWT_SECRET);
|
||||
}
|
||||
Reference in New Issue
Block a user