mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
feat(api): user/user-token (#50721)
Co-authored-by: Shaun Hamilton <shauhami020@gmail.com>
This commit is contained in:
committed by
GitHub
parent
4eeb7512e1
commit
16c0949a4b
+5
-2
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Generated
+3987
-1931
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user