feat(api): user/user-token (#50721)

Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
Oliver Eyton-Williams
2023-06-29 12:36:44 +02:00
committed by GitHub
parent 4eeb7512e1
commit 16c0949a4b
6 changed files with 4135 additions and 1933 deletions
+5 -2
View File
@@ -17,15 +17,18 @@
"fastify": "4.18.0",
"fastify-auth0-verify": "^1.0.0",
"fastify-plugin": "^4.3.0",
"jsonwebtoken": "8.5.1",
"nanoid": "3",
"nodemon": "2.0.22",
"query-string": "^7.1.3"
},
"description": "The freeCodeCamp.org open-source codebase and curriculum",
"devDependencies": {
"@fastify/type-provider-typebox": "3.2.0",
"@types/express-session": "1.17.7",
"@types/supertest": "2.0.12",
"@types/bad-words": "^3.0.1",
"@types/express-session": "1.17.7",
"@types/jsonwebtoken": "^9.0.2",
"@types/supertest": "2.0.12",
"ajv": "8.12.0",
"dotenv-cli": "7.2.1",
"jest": "29.5.0",
+99
View File
@@ -1,4 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import jwt, { JwtPayload } from 'jsonwebtoken';
import { setupServer, superRequest } from '../../jest.utils';
import { JWT_SECRET } from '../utils/env';
const baseProgressData = {
currentChallengeId: '',
@@ -87,6 +93,88 @@ describe('userRoutes', () => {
expect(user).toMatchObject(baseProgressData);
});
});
describe('/user/user-token', () => {
beforeEach(async () => {
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
await fastifyTestInstance.prisma.userToken.deleteMany({
where: {
userId: user?.id
}
});
});
// TODO(Post-MVP): consider using PUT and updating the logic to upsert
test('POST success response includes a JWT encoded string', async () => {
const response = await superRequest('/user/user-token', {
method: 'POST',
setCookies
});
const userToken = response.body.userToken;
const decodedToken = jwt.decode(userToken);
expect(response.body).toStrictEqual({ userToken: expect.any(String) });
expect(decodedToken).toStrictEqual({
userToken: expect.stringMatching(/^[a-zA-Z0-9]{64}$/),
iat: expect.any(Number)
});
expect(() => jwt.verify(userToken, 'wrong-secret')).toThrow();
expect(() => jwt.verify(userToken, JWT_SECRET)).not.toThrow();
// TODO(Post-MVP): consider using 201 for new tokens.
expect(response.status).toBe(200);
});
test('POST responds with an encoded UserToken id', async () => {
const response = await superRequest('/user/user-token', {
method: 'POST',
setCookies
});
const decodedToken = jwt.decode(response.body.userToken);
const userTokenId = (decodedToken as JwtPayload).userToken;
// Verify that the token has been created.
await fastifyTestInstance.prisma.userToken.findUniqueOrThrow({
where: { id: userTokenId }
});
// TODO(Post-MVP): consider using 201 for new tokens.
expect(response.status).toBe(200);
});
test('POST deletes old tokens when creating a new one', async () => {
const response = await superRequest('/user/user-token', {
method: 'POST',
setCookies
});
const decodedToken = jwt.decode(response.body.userToken);
const userTokenId = (decodedToken as JwtPayload).userToken;
// Verify that the token has been created.
await fastifyTestInstance.prisma.userToken.findUniqueOrThrow({
where: { id: userTokenId }
});
await superRequest('/user/user-token', {
method: 'POST',
setCookies
});
// Verify that the old token has been deleted.
expect(
await fastifyTestInstance.prisma.userToken.findUnique({
where: { id: userTokenId }
})
).toBeNull();
expect(await fastifyTestInstance.prisma.userToken.count()).toBe(1);
});
});
});
describe('Unauthenticated user', () => {
@@ -118,5 +206,16 @@ describe('userRoutes', () => {
expect(response?.statusCode).toBe(401);
});
});
describe('/user/user-token', () => {
test('POST returns 401 status code with error message', async () => {
const response = await superRequest('/user/user-token', {
method: 'POST',
setCookies
});
expect(response.statusCode).toBe(401);
});
});
});
});
+30
View File
@@ -1,6 +1,15 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { customAlphabet } from 'nanoid';
import { schemas } from '../schemas';
import { encodeUserToken } from '../utils/user-token';
// 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
);
export const userRoutes: FastifyPluginCallbackTypebox = (
fastify,
@@ -93,5 +102,26 @@ 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 }
});
const token = await fastify.prisma.userToken.create({
data: {
created: new Date(),
id: nanoid(),
userId: req.session.user.id,
// TODO(Post-MVP): expire after ttl has passed.
ttl: 77760000000 // 900 * 24 * 60 * 60 * 1000
}
});
return {
userToken: encodeUserToken(token.id)
};
});
done();
};
+7
View File
@@ -31,12 +31,18 @@ assert.ok(process.env.API_LOCATION);
assert.ok(process.env.SESSION_SECRET);
assert.ok(process.env.FCC_ENABLE_SWAGGER_UI);
assert.ok(process.env.FCC_ENABLE_DEV_LOGIN_MODE);
assert.ok(process.env.JWT_SECRET);
if (process.env.FREECODECAMP_NODE_ENV !== 'development') {
assert.ok(process.env.COOKIE_DOMAIN);
assert.ok(process.env.PORT);
assert.ok(process.env.MONGOHQ_URL);
assert.ok(process.env.SENTRY_DSN);
assert.notEqual(
process.env.JWT_SECRET,
'a_jwt_secret',
'The JWT secret should be changed from the default value.'
);
assert.notEqual(
process.env.SENTRY_DSN,
'dsn_from_sentry_dashboard',
@@ -72,3 +78,4 @@ export const SENTRY_DSN =
? ''
: process.env.SENTRY_DSN;
export const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || 'localhost';
export const JWT_SECRET = process.env.JWT_SECRET;
+7
View File
@@ -0,0 +1,7 @@
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from './env';
export function encodeUserToken(userToken: string): string {
return jwt.sign({ userToken }, JWT_SECRET);
}
+3987 -1931
View File
File diff suppressed because it is too large Load Diff