From aacfb281fb5aa9db65a4bea0a2a3ad1b3ae363b2 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Wed, 20 Mar 2024 12:47:12 +0100 Subject: [PATCH] feat(api): use jwt_access_token (in development) (#53997) Co-authored-by: Tom <20648924+moT01@users.noreply.github.com> --- api/jest.utils.ts | 8 +- api/src/app.ts | 40 ++--- api/src/plugins/code-flow-auth.test.ts | 205 +++++++++++++++++++++++++ api/src/plugins/code-flow-auth.ts | 86 +++++++++++ api/src/plugins/cookies.test.ts | 70 +++++++++ api/src/plugins/cookies.ts | 50 ++++++ api/src/plugins/cors.ts | 10 +- api/src/routes/auth-dev.test.ts | 199 ++++++++++++++++++++++++ api/src/routes/auth-dev.ts | 58 +++++++ api/src/routes/auth.test.ts | 95 ------------ api/src/routes/auth.ts | 86 +---------- api/src/routes/certificate.test.ts | 4 +- api/src/routes/certificate.ts | 4 +- api/src/routes/challenge.ts | 22 +-- api/src/routes/donate.ts | 8 +- api/src/routes/helpers/auth-helpers.ts | 27 ++++ api/src/routes/settings.ts | 32 ++-- api/src/routes/user.test.ts | 10 +- api/src/routes/user.ts | 55 +++---- api/src/utils/env.ts | 3 + api/src/utils/ids.ts | 7 + api/src/utils/redirection.ts | 1 - api/src/utils/tokens.test.ts | 28 ++++ api/src/utils/tokens.ts | 39 +++++ api/src/utils/user-token.ts | 13 -- 25 files changed, 871 insertions(+), 289 deletions(-) create mode 100644 api/src/plugins/code-flow-auth.test.ts create mode 100644 api/src/plugins/code-flow-auth.ts create mode 100644 api/src/plugins/cookies.test.ts create mode 100644 api/src/plugins/cookies.ts create mode 100644 api/src/routes/auth-dev.test.ts create mode 100644 api/src/routes/auth-dev.ts delete mode 100644 api/src/routes/auth.test.ts create mode 100644 api/src/routes/helpers/auth-helpers.ts create mode 100644 api/src/utils/ids.ts create mode 100644 api/src/utils/tokens.test.ts create mode 100644 api/src/utils/tokens.ts delete mode 100644 api/src/utils/user-token.ts diff --git a/api/jest.utils.ts b/api/jest.utils.ts index ba0f5710b45..a96107e487c 100644 --- a/api/jest.utils.ts +++ b/api/jest.utils.ts @@ -12,8 +12,8 @@ declare global { } type Options = { - sendCSRFToken: boolean; -}; + sendCSRFToken?: boolean; +} & Record; const requests = { GET: (resource: string) => request(fastifyTestInstance?.server).get(resource), @@ -187,8 +187,8 @@ export async function devLogin(): Promise { 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'); } diff --git a/api/src/app.ts b/api/src/app.ts index 991a069a5b6..42c6b7865be 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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); diff --git a/api/src/plugins/code-flow-auth.test.ts b/api/src/plugins/code-flow-auth.test.ts new file mode 100644 index 00000000000..b37361fc789 --- /dev/null +++ b/api/src/plugins/code-flow-auth.test.ts @@ -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 }); + }); + }); +}); diff --git a/api/src/plugins/code-flow-auth.ts b/api/src/plugins/code-flow-auth.ts new file mode 100644 index 00000000000..ef0def1710b --- /dev/null +++ b/api/src/plugins/code-flow-auth.ts @@ -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); diff --git a/api/src/plugins/cookies.test.ts b/api/src/plugins/cookies.test.ts new file mode 100644 index 00000000000..b1314ba9d57 --- /dev/null +++ b/api/src/plugins/cookies.test.ts @@ -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 } + }); + }); +}); diff --git a/api/src/plugins/cookies.ts b/api/src/plugins/cookies.ts new file mode 100644 index 00000000000..844c7b6da4c --- /dev/null +++ b/api/src/plugins/cookies.ts @@ -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); diff --git a/api/src/plugins/cors.ts b/api/src/plugins/cors.ts index 0cc071c5f4c..004ef99cd0a 100644 --- a/api/src/plugins/cors.ts +++ b/api/src/plugins/cors.ts @@ -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) => { diff --git a/api/src/routes/auth-dev.test.ts b/api/src/routes/auth-dev.test.ts new file mode 100644 index 00000000000..79e1ce7932b --- /dev/null +++ b/api/src/routes/auth-dev.test.ts @@ -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); + }); + }); +}); diff --git a/api/src/routes/auth-dev.ts b/api/src/routes/auth-dev.ts new file mode 100644 index 00000000000..bf00b39719d --- /dev/null +++ b/api/src/routes/auth-dev.ts @@ -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(); +}; diff --git a/api/src/routes/auth.test.ts b/api/src/routes/auth.test.ts deleted file mode 100644 index 83f697fd0ae..00000000000 --- a/api/src/routes/auth.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts index 47bb210b850..a2e69d4170a 100644 --- a/api/src/routes/auth.ts +++ b/api/src/routes/auth.ts @@ -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(); -}; diff --git a/api/src/routes/certificate.test.ts b/api/src/routes/certificate.test.ts index 759f875792b..198864adec1 100644 --- a/api/src/routes/certificate.test.ts +++ b/api/src/routes/certificate.test.ts @@ -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( diff --git a/api/src/routes/certificate.ts b/api/src/routes/certificate.ts index c1c125b9fbc..0386774b2bd 100644 --- a/api/src/routes/certificate.ts +++ b/api/src/routes/certificate.ts @@ -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) { diff --git a/api/src/routes/challenge.ts b/api/src/routes/challenge.ts index f596f8eee0c..7133b0ac3ee 100644 --- a/api/src/routes/challenge.ts +++ b/api/src/routes/challenge.ts @@ -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 } = diff --git a/api/src/routes/donate.ts b/api/src/routes/donate.ts index cb76a7fcaf6..d479c8da621 100644 --- a/api/src/routes/donate.ts +++ b/api/src/routes/donate.ts @@ -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 } diff --git a/api/src/routes/helpers/auth-helpers.ts b/api/src/routes/helpers/auth-helpers.ts new file mode 100644 index 00000000000..b82da1d28c8 --- /dev/null +++ b/api/src/routes/helpers/auth-helpers.ts @@ -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 } + })) + ); +}; diff --git a/api/src/routes/settings.ts b/api/src/routes/settings.ts index 70c880b23c2..708594a4f46 100644 --- a/api/src/routes/settings.ts +++ b/api/src/routes/settings.ts @@ -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 } diff --git a/api/src/routes/user.test.ts b/api/src/routes/user.test.ts index 63786cd3d16..57822dc6c34 100644 --- a/api/src/routes/user.test.ts +++ b/api/src/routes/user.test.ts @@ -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 = { diff --git a/api/src/routes/user.ts b/api/src/routes/user.ts index ca2214b7908..236c0807969 100644 --- a/api/src/routes/user.ts +++ b/api/src/routes/user.ts @@ -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([ diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 58a85429b8e..131cb0c8aab 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -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; diff --git a/api/src/utils/ids.ts b/api/src/utils/ids.ts new file mode 100644 index 00000000000..9e63a2a3a9d --- /dev/null +++ b/api/src/utils/ids.ts @@ -0,0 +1,7 @@ +import { customAlphabet } from 'nanoid'; + +// uppercase, lowercase letters and numbers +export const customNanoid = customAlphabet( + '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', + 64 +); diff --git a/api/src/utils/redirection.ts b/api/src/utils/redirection.ts index 5328a909596..40520e6af00 100644 --- a/api/src/utils/redirection.ts +++ b/api/src/utils/redirection.ts @@ -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'; diff --git a/api/src/utils/tokens.test.ts b/api/src/utils/tokens.test.ts new file mode 100644 index 00000000000..b79877c36b5 --- /dev/null +++ b/api/src/utils/tokens.test.ts @@ -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); + }); +}); diff --git a/api/src/utils/tokens.ts b/api/src/utils/tokens.ts new file mode 100644 index 00000000000..553ea61b854 --- /dev/null +++ b/api/src/utils/tokens.ts @@ -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() + }; +}; diff --git a/api/src/utils/user-token.ts b/api/src/utils/user-token.ts deleted file mode 100644 index d9bb1c03cba..00000000000 --- a/api/src/utils/user-token.ts +++ /dev/null @@ -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); -}