feat: migrate from express to fastify

This commit is contained in:
Divlo
2021-10-24 04:18:18 +02:00
parent 714cc643ba
commit b77e602358
281 changed files with 19768 additions and 22895 deletions

View File

@ -1,18 +0,0 @@
/users/{userId}:
get:
tags:
- 'users'
summary: 'GET the user information with its id'
parameters:
- name: 'userId'
in: 'path'
required: true
responses:
allOf:
- $ref: '#/definitions/NotFoundError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/User'

View File

@ -1,41 +1,20 @@
import request from 'supertest'
import { application } from '../../../../application.js'
import { userExample } from '../../../../models/User.js'
import { userSettingsExample } from '../../../../models/UserSettings.js'
import { prismaMock } from '../../../../__test__/setup.js'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
describe('GET /users/:userId', () => {
it('should returns the user without the email', async () => {
const { userId } = await authenticateUserTest()
const response = await request(application)
.get(`/users/${userId}`)
.send()
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.user.email).toBeUndefined()
expect(response.body.user.id).toEqual(userId)
})
it('should returns the user with the email', async () => {
const userToken = await authenticateUserTest()
await request(application)
.put('/users/current/settings')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ isPublicEmail: true })
.expect(200)
const response = await request(application)
.get(`/users/${userToken.userId}`)
.send()
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.user.email).not.toBeNull()
expect(response.body.user.id).toEqual(userToken.userId)
})
it("should returns 404 error if the user doesn't exist", async () => {
const response = await request(application).get('/users/1').send().expect(404)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(expect.arrayContaining(['Not Found']))
describe('GET /users/[userId]', () => {
it('succeeds', async () => {
prismaMock.guild.findMany.mockResolvedValue([])
prismaMock.user.findUnique.mockResolvedValue(userExample)
prismaMock.userSetting.findFirst.mockResolvedValue(userSettingsExample)
const response = await application.inject({
method: 'GET',
url: `/users/${userExample.id}`
})
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.user.id).toEqual(userExample.id)
expect(responseJson.user.name).toEqual(userExample.name)
})
})

View File

@ -1,30 +1,84 @@
import { Request, Response, Router } from 'express'
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import User from '../../../models/User'
import UserSetting from '../../../models/UserSetting'
import { NotFoundError } from '../../../tools/errors/NotFoundError'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import { userPublicSchema } from '../../../models/User.js'
import { guildSchema } from '../../../models/Guild.js'
export const getUsersRouter = Router()
const parametersGetUserSchema = Type.Object({
userId: userPublicSchema.id
})
getUsersRouter.get(
'/users/:userId',
[],
async (req: Request, res: Response) => {
const { userId } = req.params as { userId: string }
const user = await User.findOne({ where: { id: userId } })
if (user == null) {
throw new NotFoundError()
}
const userSettings = await UserSetting.findOne({
where: { userId: user.id }
})
if (userSettings == null) {
throw new NotFoundError()
}
const result = Object.assign({}, user.toJSON())
if (!userSettings.isPublicEmail) {
delete result.email
}
return res.status(200).json({ user: result })
export type ParametersGetUser = Static<typeof parametersGetUserSchema>
const getServiceSchema: FastifySchema = {
description: 'GET the public user informations with its id',
tags: ['users'] as string[],
params: parametersGetUserSchema,
response: {
200: Type.Object({
user: Type.Object(userPublicSchema),
guilds: Type.Union([Type.Array(Type.Object(guildSchema)), Type.Null()])
}),
400: fastifyErrors[400],
404: fastifyErrors[404],
500: fastifyErrors[500]
}
)
} as const
export const getUserById: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Params: ParametersGetUser
}>({
method: 'GET',
url: '/users/:userId',
schema: getServiceSchema,
handler: async (request, reply) => {
const { userId } = request.params
const settings = await prisma.userSetting.findFirst({
where: { userId }
})
if (settings == null) {
throw fastify.httpErrors.notFound('User not found')
}
const user = await prisma.user.findUnique({
where: {
id: userId
},
select: {
id: true,
name: true,
email: settings.isPublicEmail,
logo: true,
status: true,
biography: true,
website: true,
createdAt: true,
updatedAt: true
}
})
if (user == null) {
throw fastify.httpErrors.notFound('User not found')
}
reply.statusCode = 200
return {
user: {
...user,
settings
},
guilds: !settings.isPublicGuilds
? null
: await prisma.guild.findMany({
where: {
members: {
some: {
userId
}
}
}
})
}
}
})
}

View File

@ -1,7 +0,0 @@
import { Router } from 'express'
import { getUsersRouter } from './get'
export const usersGetByIdRouter = Router()
usersGetByIdRouter.use('/', getUsersRouter)

View File

@ -1,99 +0,0 @@
definitions:
User:
type: 'object'
properties:
user:
type: object
properties:
id:
type: 'integer'
description: 'Unique id'
name:
type: 'string'
description: 'Unique name'
email:
type: 'string'
description: 'Unique email address'
status:
type: 'string'
biography:
type: 'string'
logo:
type: 'string'
isConfirmed:
type: 'boolean'
createdAt:
type: 'string'
format: 'date-time'
updatedAt:
type: 'string'
format: 'date-time'
Language:
type: 'string'
enum: ['en', 'fr']
default: 'en'
Theme:
type: 'string'
enum: ['dark', 'light']
default: 'dark'
UserSettings:
type: 'object'
properties:
language:
allOf:
- $ref: '#/definitions/Language'
theme:
allOf:
- $ref: '#/definitions/Theme'
isPublicEmail:
type: 'boolean'
UserSettingsObject:
type: 'object'
properties:
settings:
allOf:
- $ref: '#/definitions/UserSettings'
AuthenticationStrategy:
type: 'string'
enum: ['local', 'google', 'github', 'discord']
UserStrategies:
type: 'object'
properties:
strategies:
type: 'array'
items:
allOf:
- $ref: '#/definitions/AuthenticationStrategy'
UserCurrentStrategy:
type: 'object'
properties:
currentStrategy:
allOf:
- $ref: '#/definitions/AuthenticationStrategy'
AccessTokenResponse:
type: 'object'
properties:
accessToken:
type: 'string'
expiresIn:
type: 'number'
description: 'expiresIn is how long, in milliseconds, until the returned accessToken expires'
type:
type: 'string'
enum: ['Bearer']
RefreshTokenResponse:
allOf:
- $ref: '#/definitions/AccessTokenResponse'
- type: 'object'
properties:
refreshToken:
type: 'string'

View File

@ -1,89 +0,0 @@
import OAuth, { ProviderOAuth } from '../../../models/OAuth'
import User, { UserRequest } from '../../../models/User'
import UserSetting from '../../../models/UserSetting'
import {
expiresIn,
generateAccessToken,
generateRefreshToken,
ResponseJWT
} from '../../../tools/configurations/jwtToken'
interface ProviderData {
name: string
id: number | string
}
type ResponseCallbackAddStrategy =
| 'success'
| 'This account is already used by someone else'
| 'You are already using this account'
export class OAuthStrategy {
constructor (public provider: ProviderOAuth) {}
async callbackAddStrategy (
providerData: ProviderData,
userRequest: UserRequest
): Promise<ResponseCallbackAddStrategy> {
const OAuthUser = await OAuth.findOne({
where: { providerId: providerData.id, provider: this.provider }
})
let message: ResponseCallbackAddStrategy = 'success'
if (OAuthUser == null) {
await OAuth.create({
provider: this.provider,
providerId: providerData.id,
userId: userRequest.current.id
})
} else if (OAuthUser.userId !== userRequest.current.id) {
message = 'This account is already used by someone else'
} else {
message = 'You are already using this account'
}
return message
}
async callbackSignin (providerData: ProviderData): Promise<ResponseJWT> {
const OAuthUser = await OAuth.findOne({
where: { providerId: providerData.id, provider: this.provider }
})
let userId: number = OAuthUser?.userId ?? 0
if (OAuthUser == null) {
let name = providerData.name
let isAlreadyUsedName = true
let countId: string | number = providerData.id
while (isAlreadyUsedName) {
const foundUsers = await User.count({ where: { name } })
isAlreadyUsedName = foundUsers > 0
if (isAlreadyUsedName) {
name = `${name}-${countId}`
countId = Math.random() * Date.now()
}
}
const user = await User.create({ name })
await UserSetting.create({ userId: user.id })
userId = user.id
await OAuth.create({
provider: this.provider,
providerId: providerData.id,
userId: user.id
})
}
const accessToken = generateAccessToken({
currentStrategy: this.provider,
id: userId
})
const refreshToken = await generateRefreshToken({
currentStrategy: this.provider,
id: userId
})
return {
accessToken,
refreshToken,
expiresIn,
type: 'Bearer'
}
}
}

View File

@ -1,119 +0,0 @@
import { OAuthStrategy } from '../OAuthStrategy'
import OAuth from '../../../../models/OAuth'
import User from '../../../../models/User'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import RefreshToken from '../../../../models/RefreshToken'
const oauthStrategy = new OAuthStrategy('discord')
describe('/users/utils/OAuthStrategy - callbackSignin', () => {
it('should signup the user', async () => {
let users = await User.findAll()
let oauths = await OAuth.findAll()
expect(users.length).toEqual(0)
expect(oauths.length).toEqual(0)
const name = 'Martin'
const id = '12345'
await oauthStrategy.callbackSignin({ id, name })
users = await User.findAll()
oauths = await OAuth.findAll()
expect(users.length).toEqual(1)
expect(oauths.length).toEqual(1)
expect(users[0].name).toEqual(name)
expect(oauths[0].providerId).toEqual(id)
expect(oauths[0].provider).toEqual(oauthStrategy.provider)
})
it('should signup the user and generate a new name when already used', async () => {
const oauths = await OAuth.findAll()
expect(oauths.length).toEqual(0)
const name = 'Martin'
const id = '1234'
await authenticateUserTest({
name,
shouldBeConfirmed: true,
email: 'martin@example.com',
password: 'password'
})
await oauthStrategy.callbackSignin({ id, name })
const oauth = await OAuth.findOne({ where: { providerId: id } })
expect(oauth?.provider).toEqual(oauthStrategy.provider)
expect(oauth?.providerId).toEqual(id)
expect(oauth?.userId).toEqual(2)
const user = await User.findByPk(oauth?.userId)
expect(user?.name.startsWith(name)).toBeTruthy()
expect(user?.name).not.toEqual(name)
})
it('should signin the user if already connected with the provider', async () => {
const name = 'Martin'
const id = '1234'
await oauthStrategy.callbackSignin({ id, name })
let oauths = await OAuth.findAll()
expect(oauths.length).toEqual(1)
await oauthStrategy.callbackSignin({ id, name })
oauths = await OAuth.findAll()
expect(oauths.length).toEqual(1)
})
})
describe('/users/utils/OAuthStrategy - callbackAddStrategy', () => {
it('should add the strategy', async () => {
const userTokens = await authenticateUserTest()
const user = await User.findOne({ where: { id: userTokens.userId } })
expect(user).not.toBeNull()
if (user != null) {
const result = await oauthStrategy.callbackAddStrategy(
{ name: user.name, id: '1234' },
{
current: user,
accessToken: userTokens.accessToken,
currentStrategy: 'local'
}
)
expect(result).toEqual('success')
}
})
it('should not add the strategy if the account of the provider is already used', async () => {
const userTokens = await authenticateUserTest()
const user = await User.findOne({ where: { id: userTokens.userId } })
const name = 'Martin'
const id = '1234'
await oauthStrategy.callbackSignin({ id, name })
expect(user).not.toBeNull()
if (user != null) {
const result = await oauthStrategy.callbackAddStrategy(
{ name: user.name, id: '1234' },
{
current: user,
accessToken: userTokens.accessToken,
currentStrategy: 'local'
}
)
expect(result).toEqual('This account is already used by someone else')
}
})
it('should not add the strategy if the user is already connected with it', async () => {
const name = 'Martin'
const id = '1234'
const userTokens = await oauthStrategy.callbackSignin({ id, name })
const refreshToken = await RefreshToken.findOne({
where: { token: userTokens.refreshToken as string },
include: [{ model: User }]
})
expect(refreshToken).not.toBeNull()
if (refreshToken != null) {
const result = await oauthStrategy.callbackAddStrategy(
{ name: refreshToken.user.name, id: '1234' },
{
current: refreshToken.user,
accessToken: userTokens.accessToken,
currentStrategy: oauthStrategy.provider
}
)
expect(result).toEqual('You are already using this account')
}
})
})

View File

@ -1,20 +0,0 @@
import { buildQueryURL } from '../buildQueryURL'
test('controllers/users/utils/buildQueryUrl', () => {
expect(
buildQueryURL('http://localhost:8080', {
test: 'query'
})
).toEqual('http://localhost:8080/?test=query')
expect(
buildQueryURL('http://localhost:8080/', {
test: 'query'
})
).toEqual('http://localhost:8080/?test=query')
expect(
buildQueryURL('http://localhost:3000', {
test: 'query',
code: 'abc'
})
).toEqual('http://localhost:3000/?test=query&code=abc')
})

View File

@ -1,12 +0,0 @@
import { ObjectAny } from '../../../typings/utils'
export const buildQueryURL = (
baseURL: string,
queryObject: ObjectAny
): string => {
const url = new URL(baseURL)
Object.entries(queryObject).forEach(([query, value]) => {
url.searchParams.append(query, value)
})
return url.href
}

View File

@ -1,12 +0,0 @@
import RefreshToken from '../../../models/RefreshToken'
export const deleteEveryRefreshTokens = async (
userId: number
): Promise<void> => {
const refreshTokens = await RefreshToken.findAll({
where: { userId }
})
for (const refreshToken of refreshTokens) {
await refreshToken.destroy()
}
}

View File

@ -1,38 +0,0 @@
/users/addLocalStrategy:
post:
security:
- bearerAuth: []
tags:
- 'users'
summary: 'Allows a user to add the local strategy.'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
email:
type: 'string'
format: 'email'
password:
type: 'string'
format: 'password'
example: 'password'
required:
- 'email'
- 'password'
parameters:
- name: 'redirectURI'
description: 'The redirect URI to redirect the user when he successfuly confirm his email (could be a signin page), if not provided it will redirect the user to a simple page with a message to tell the user he can now signin.'
in: 'query'
required: false
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- '201':
content:
application/json:
schema:
$ref: '#/definitions/User'

View File

@ -1,76 +0,0 @@
import request from 'supertest'
import application from '../../../../application'
import User from '../../../../models/User'
import { generateAccessToken } from '../../../../tools/configurations/jwtToken'
describe('POST /users/addLocalStrategy', () => {
it('succeeds and add local strategy', async () => {
const user = await User.create({ name: 'John' })
const accessToken = generateAccessToken({
currentStrategy: 'github',
id: user.id
})
const email = 'johndoe@example.com'
const response = await request(application)
.post('/users/addLocalStrategy')
.send({
email,
password: 'password'
})
.set('Authorization', `Bearer ${accessToken}`)
.expect(201)
expect(response.body.user).not.toBeNull()
expect(response.body.user.id).toEqual(user.id)
expect(response.body.user.email).toEqual(email)
})
it('fails if the user is already using local strategy', async () => {
const user = await User.create({ name: 'John' })
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
const email = 'johndoe@example.com'
const response = await request(application)
.post('/users/addLocalStrategy')
.send({
email,
password: 'password'
})
.set('Authorization', `Bearer ${accessToken}`)
.expect(400)
expect(response.body.errors.length).toEqual(1)
})
it('fails with invalid email', async () => {
const user = await User.create({ name: 'John' })
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
const email = 'johndoecom'
const response = await request(application)
.post('/users/addLocalStrategy')
.send({
email,
password: 'password'
})
.set('Authorization', `Bearer ${accessToken}`)
.expect(400)
expect(response.body.errors.length).toEqual(1)
})
it('fails if the user is not connected', async () => {
const email = 'johndoecom'
const response = await request(application)
.post('/users/addLocalStrategy')
.send({
email,
password: 'password'
})
.set('Authorization', 'Bearer token')
.expect(403)
expect(response.body.errors.length).toEqual(1)
})
})

View File

@ -1,79 +0,0 @@
import bcrypt from 'bcryptjs'
import { Request, Response, Router } from 'express'
import { body, query } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import UserSetting from '../../../models/UserSetting'
import { sendEmail } from '../../../tools/email/sendEmail'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { alreadyUsedValidation } from '../../../tools/validations/alreadyUsedValidation'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid',
alreadyConnected: 'You are already connected with this email address'
}
}
export const addLocalStrategyRouter = Router()
addLocalStrategyRouter.post(
'/users/addLocalStrategy',
authenticateUser,
[
body('email')
.trim()
.notEmpty()
.isEmail()
.withMessage(errorsMessages.email.mustBeValid)
.custom(async (email: string, meta) => {
if (email === meta.req.user?.current.email) {
return await Promise.reject(
new Error(errorsMessages.email.alreadyConnected)
)
}
return await alreadyUsedValidation(User, 'email', email)
}),
body('password').trim().notEmpty().isString(),
query('redirectURI').optional({ nullable: true }).trim()
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { email, password } = req.body as {
email: string
password: string
}
const { redirectURI } = req.query as { redirectURI?: string }
if (req.user.currentStrategy === 'local' || user.password != null) {
throw new BadRequestError('You are already using local strategy')
}
const hashedPassword = await bcrypt.hash(password, 12)
const tempToken = uuidv4()
user.email = email
user.password = hashedPassword
user.tempToken = tempToken
user.isConfirmed = false
await user.save()
const userSettings = await UserSetting.findOne({
where: { userId: user.id }
})
const redirectQuery =
redirectURI != null ? `&redirectURI=${redirectURI}` : ''
await sendEmail({
type: 'confirm-email',
email,
url: `${process.env.API_BASE_URL}/users/confirmEmail?tempToken=${tempToken}${redirectQuery}`,
language: userSettings?.language,
theme: userSettings?.theme
})
return res.status(201).json({ user })
}
)

View File

@ -0,0 +1,39 @@
import { application } from '../../../../application.js'
import { userExample } from '../../../../models/User.js'
import { prismaMock } from '../../../../__test__/setup.js'
describe('GET /users/confirm-email', () => {
it('should succeeds', async () => {
prismaMock.user.findFirst.mockResolvedValue(userExample)
prismaMock.user.update.mockResolvedValue({
...userExample,
isConfirmed: true,
temporaryToken: null
})
const response = await application.inject({
method: 'GET',
url: '/users/confirm-email',
query: {
temporaryToken: userExample.temporaryToken ?? ''
}
})
expect(response.statusCode).toEqual(200)
})
it('should fails with invalid `temporaryToken`', async () => {
prismaMock.user.findFirst.mockResolvedValue(null)
prismaMock.user.update.mockResolvedValue({
...userExample,
isConfirmed: true,
temporaryToken: null
})
const response = await application.inject({
method: 'GET',
url: '/users/confirm-email',
query: {
temporaryToken: userExample.temporaryToken ?? ''
}
})
expect(response.statusCode).toEqual(403)
})
})

View File

@ -0,0 +1,59 @@
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import { userSchema } from '../../../models/User.js'
const queryGetConfirmEmailSchema = Type.Object({
redirectURI: Type.Optional(Type.String({ format: 'uri-reference' })),
temporaryToken: userSchema.temporaryToken
})
type QueryGetConfirmEmailSchemaType = Static<typeof queryGetConfirmEmailSchema>
const getConfirmEmailSchema: FastifySchema = {
description: 'Confirm the account of the user.',
tags: ['users'] as string[],
querystring: queryGetConfirmEmailSchema,
response: {
200: Type.String(),
400: fastifyErrors[400],
403: fastifyErrors[403],
500: fastifyErrors[500]
}
} as const
export const getConfirmEmail: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Querystring: QueryGetConfirmEmailSchemaType
}>({
method: 'GET',
url: '/users/confirm-email',
schema: getConfirmEmailSchema,
handler: async (request, reply) => {
const { redirectURI, temporaryToken } = request.query
const user = await prisma.user.findFirst({
where: {
temporaryToken,
isConfirmed: false
}
})
if (user == null) {
throw fastify.httpErrors.forbidden()
}
await prisma.user.update({
where: { id: user.id },
data: {
temporaryToken: null,
isConfirmed: true
}
})
if (redirectURI == null) {
reply.statusCode = 200
return 'Success, your email has been confirmed, you can now signin!'
}
await reply.redirect(redirectURI)
}
})
}

View File

@ -1,22 +0,0 @@
/users/confirmEmail:
get:
tags:
- 'users'
summary: 'Confirm the account of the user'
parameters:
- name: 'tempToken'
in: 'query'
required: true
- name: 'redirectURI'
description: 'The redirect URI to redirect the user when he successfuly confirm his email.'
in: 'query'
required: false
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/ForbiddenError'
- '200':
content:
application/json:
schema:
$ref: '#/definitions/AccessTokenResponse'

View File

@ -1,43 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import User from '../../../../models/User'
describe('GET /users/confirmEmail', () => {
it('succeeds and confirm the user', async () => {
const name = 'John'
await authenticateUserTest({
name,
email: 'contact@john.com',
shouldBeConfirmed: false
})
const user = await User.findOne({ where: { name } })
expect(user).not.toBeNull()
expect(user?.isConfirmed).toBe(false)
await request(application)
.get(`/users/confirmEmail?tempToken=${user?.tempToken as string}`)
.send()
.expect(200)
const foundUser = await User.findOne({ where: { name } })
expect(foundUser).not.toBeNull()
expect(foundUser?.isConfirmed).toBe(true)
expect(foundUser?.tempToken).toBe(null)
})
it('fails with invalid tempToken', async () => {
await request(application)
.get('/users/confirmEmail?tempToken=mybadtoken')
.send()
.expect(403)
})
it('fails with empty tempToken', async () => {
await request(application)
.get('/users/confirmEmail')
.send()
.expect(400)
})
})

View File

@ -1,48 +0,0 @@
import { Request, Response, Router } from 'express'
import { query } from 'express-validator'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { isValidRedirectURIValidation } from '../../../tools/validations/isValidRedirectURIValidation'
export const confirmEmailRouter = Router()
confirmEmailRouter.get(
'/users/confirmEmail',
[
query('tempToken')
.trim()
.notEmpty(),
query('redirectURI')
.optional({ nullable: true })
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
async (req: Request, res: Response) => {
const { tempToken, redirectURI } = req.query as {
tempToken: string
redirectURI?: string
}
const user = await User.findOne({
where: { tempToken, isConfirmed: false }
})
if (user == null) {
throw new ForbiddenError()
}
user.tempToken = null
user.isConfirmed = true
await user.save()
if (redirectURI == null) {
return res
.status(200)
.json('Success, your email has been confirmed, you can now signin!')
}
return res.redirect(redirectURI)
}
)

View File

@ -1,21 +0,0 @@
/users/current:
get:
security:
- bearerAuth: []
tags:
- 'users'
summary: 'GET the current connected user'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/User'
- $ref: '#/definitions/UserSettingsObject'
- $ref: '#/definitions/UserCurrentStrategy'
- $ref: '#/definitions/UserStrategies'

View File

@ -1,45 +0,0 @@
/users/current:
put:
security:
- bearerAuth: []
tags:
- 'users'
summary: 'Edit the current connected user info'
requestBody:
content:
multipart/form-data:
schema:
type: 'object'
properties:
email:
type: 'string'
format: 'email'
name:
type: 'string'
minLength: 3
maxLength: 30
example: 'user'
biography:
type: 'string'
maxLength: 160
example: 'biography'
status:
type: 'string'
maxLength: 100
example: '👀 Working on secrets projects...'
logo:
type: 'string'
format: 'binary'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/PayloadTooLargeError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/User'
- $ref: '#/definitions/UserCurrentStrategy'

View File

@ -1,46 +1,29 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import { application } from '../../../../application.js'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUserTest.js'
describe('GET /users/current', () => {
it('succeeds with valid Bearer accessToken', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.get('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.settings).not.toBeNull()
expect(response.body.currentStrategy).toEqual('local')
expect(Array.isArray(response.body.strategies)).toBeTruthy()
expect(response.body.strategies.includes('local')).toBeTruthy()
it('succeeds', async () => {
const { accessToken, user } = await authenticateUserTest()
const response = await application.inject({
method: 'GET',
url: '/users/current',
headers: {
authorization: `Bearer ${accessToken}`
}
})
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.user.name).toEqual(user.name)
expect(responseJson.user.strategies).toEqual(
expect.arrayContaining(['local'])
)
})
it('fails with unconfirmed account', async () => {
const userToken = await authenticateUserTest({ shouldBeConfirmed: false })
const response = await request(application)
.get('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(401)
expect(response.body.errors.length).toEqual(1)
})
it('fails ForbiddenError with invalid Bearer accessToken', async () => {
await request(application)
.get('/users/current')
.set('Authorization', 'Bearer invalidtoken')
.send()
.expect(403)
})
it('fails NotAuthorizedError with invalid accessToken', async () => {
await request(application)
.get('/users/current')
.set('Authorization', 'invalidtoken')
.send()
.expect(401)
it('fails with unauthenticated user', async () => {
const response = await application.inject({
method: 'GET',
url: '/users/current'
})
expect(response.statusCode).toEqual(401)
})
})

View File

@ -1,228 +1,107 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import User from '../../../../models/User'
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
import { randomString } from '../../../../tools/utils/random'
import { errorsMessages } from '../index'
import { application } from '../../../../application.js'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUserTest.js'
import { prismaMock } from '../../../../__test__/setup.js'
import { userExample } from '../../../../models/User.js'
describe('PUT /users/current', () => {
it('succeeds with valid accessToken, valid email and valid name', async () => {
const name = 'test2'
const email = 'test2@test2.com'
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name, email })
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.user.name).toBe(name)
expect(response.body.user.email).toBe(email)
expect(response.body.currentStrategy).not.toBeNull()
})
it('succeeds and only change the email', async () => {
const name = 'John'
const email = 'contact@test.com'
const userToken = await authenticateUserTest({
name,
email
it('succeeds with valid accessToken and valid name', async () => {
const newName = 'John Doe'
const { accessToken, user } = await authenticateUserTest()
prismaMock.user.update.mockResolvedValue({
...user,
name: newName
})
let user = (await User.findAll())[0]
expect(user.email).toEqual(email)
expect(user.name).toEqual(name)
const email2 = 'test2@test2.com'
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ email: email2 })
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.user.email).toEqual(email2)
expect(response.body.user.name).toEqual(name)
user = (await User.findAll())[0]
expect(user.email).toEqual(email2)
expect(user.name).toEqual(name)
expect(user.isConfirmed).toBe(false)
})
it('succeeds and only change the name', async () => {
const name = 'John'
const email = 'contact@test.com'
const userToken = await authenticateUserTest({
name,
email
const response = await application.inject({
method: 'PUT',
url: '/users/current',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: {
name: newName
}
})
let user = (await User.findAll())[0]
expect(user.email).toEqual(email)
expect(user.name).toEqual(name)
const name2 = 'test2'
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name: name2 })
.expect(200)
expect(response.body.user).not.toBeNull()
user = (await User.findAll())[0]
expect(user.email).toEqual(email)
expect(user.name).toEqual(name2)
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.user.name).toEqual(newName)
})
it('succeeds and only change the status', async () => {
const userToken = await authenticateUserTest()
const status = '👀 Working on secret projects...'
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ status })
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.user.status).toBe(status)
})
it('succeeds and only change the biography', async () => {
const userToken = await authenticateUserTest()
const biography = 'My awesome biography'
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ biography })
.expect(200)
expect(response.body.user).not.toBeNull()
expect(response.body.user.biography).toBe(biography)
})
it('fails with unconfirmed account', async () => {
const userToken = await authenticateUserTest({
name: 'John',
email: 'contact@john.com',
shouldBeConfirmed: false
it('succeeds and only update the status', async () => {
const newStatus = '👀 Working on secret projects...'
const { accessToken, user } = await authenticateUserTest()
prismaMock.user.update.mockResolvedValue({
...user,
status: newStatus
})
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(401)
expect(response.body.errors.length).toEqual(1)
})
it('fails with invalid status', async () => {
const userToken = await authenticateUserTest()
const status = randomString(110)
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ status })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toEqual(
commonErrorsMessages.charactersLength('status', { max: 100 })
)
})
it('fails with invalid biography', async () => {
const userToken = await authenticateUserTest()
const biography = randomString(170)
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ biography })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toEqual(
commonErrorsMessages.charactersLength('biography', { max: 160 })
)
})
it('fails with invalid name and invalid email', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({
name: 'jo',
email: 'test2@test2'
})
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(2)
expect(errors).toEqual(
expect.arrayContaining([
errorsMessages.email.mustBeValid,
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
])
)
})
it('fails with name and email already used', async () => {
const firstUserName = 'Test'
const firstUserEmail = 'test@test.com'
await authenticateUserTest({
name: firstUserName,
email: firstUserEmail,
shouldBeConfirmed: true
const response = await application.inject({
method: 'PUT',
url: '/users/current',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: {
status: newStatus
}
})
const secondUserToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current')
.set(
'Authorization',
`${secondUserToken.type} ${secondUserToken.accessToken}`
)
.send({
name: firstUserName,
email: firstUserEmail
})
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(2)
expect(errors).toEqual(
expect.arrayContaining(['Name already used', 'Email already used'])
)
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.user.name).toEqual(user.name)
expect(responseJson.user.status).toEqual(newStatus)
})
it('fails with name identical to the current user name', async () => {
const name = 'Test'
const email = 'test@test.com'
const userToken = await authenticateUserTest({
name,
email
it('fails with name already used', async () => {
const newName = 'John Doe'
prismaMock.user.findFirst.mockResolvedValue(userExample)
const { accessToken } = await authenticateUserTest()
const response = await application.inject({
method: 'PUT',
url: '/users/current',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: {
name: newName
}
})
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ name })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.name.alreadyConnected])
)
expect(response.statusCode).toEqual(400)
})
it('fails with email identical to the current user email', async () => {
const name = 'Test'
const email = 'test@test.com'
const userToken = await authenticateUserTest({
name,
email
it('fails with invalid website url', async () => {
const newWebsite = 'invalid website url'
const { accessToken } = await authenticateUserTest()
const response = await application.inject({
method: 'PUT',
url: '/users/current',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: {
website: newWebsite
}
})
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ email })
.expect(400)
expect(response.statusCode).toEqual(400)
})
const errors = formatErrors(response.body.errors)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.email.alreadyConnected])
)
it('suceeds with valid website url', async () => {
const newWebsite = 'https://somerandomwebsite.com'
const { accessToken, user } = await authenticateUserTest()
prismaMock.user.update.mockResolvedValue({
...user,
website: newWebsite
})
const response = await application.inject({
method: 'PUT',
url: '/users/current',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: {
website: newWebsite
}
})
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.user.name).toEqual(user.name)
expect(responseJson.user.website).toEqual(newWebsite)
})
})

View File

@ -1,36 +1,59 @@
import { Request, Response, Router } from 'express'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import OAuth, { AuthenticationStrategy } from '../../../models/OAuth'
import UserSetting from '../../../models/UserSetting'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import authenticateUser from '../../../tools/plugins/authenticateUser.js'
import { userCurrentSchema } from '../../../models/User.js'
export const getCurrentRouter = Router()
getCurrentRouter.get(
'/users/current',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
const getCurrentUserSchema: FastifySchema = {
description: 'GET the current connected user',
tags: ['users'] as string[],
security: [
{
bearerAuth: []
}
const settings = await UserSetting.findOne({
where: { userId: req.user.current.id }
})
const OAuths = await OAuth.findAll({
where: { userId: req.user.current.id }
})
const strategies: AuthenticationStrategy[] = OAuths.map((oauth) => {
return oauth.provider
})
if (req.user.current.password != null) {
strategies.push('local')
}
return res.status(200).json({
user: req.user.current,
settings,
currentStrategy: req.user.currentStrategy,
strategies
})
] as Array<{ [key: string]: [] }>,
response: {
200: userCurrentSchema,
400: fastifyErrors[400],
401: fastifyErrors[401],
403: fastifyErrors[403],
500: fastifyErrors[500]
}
)
} as const
export const getCurrentUser: FastifyPluginAsync = async (fastify) => {
await fastify.register(authenticateUser)
fastify.route({
method: 'GET',
url: '/users/current',
schema: getCurrentUserSchema,
handler: async (request, reply) => {
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
const settings = await prisma.userSetting.findFirst({
where: { userId: request.user.current.id }
})
const OAuths = await prisma.oAuth.findMany({
where: { userId: request.user.current.id }
})
const strategies = OAuths.map((oauth) => {
return oauth.provider
})
if (request.user.current.password != null) {
strategies.push('local')
}
reply.statusCode = 200
return {
user: {
...request.user.current,
settings,
currentStrategy: request.user.currentStrategy,
strategies
}
}
}
})
}

View File

@ -1,21 +0,0 @@
import { Router } from 'express'
import { getCurrentRouter } from './get'
import { putCurrentRouter } from './put'
import { currentSettingsRouter } from './settings'
export const currentRouter = Router()
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid',
alreadyConnected: 'You are already connected with this email address'
},
name: {
alreadyConnected: 'You are already connected with this name'
}
}
currentRouter.use('/', getCurrentRouter)
currentRouter.use('/', putCurrentRouter)
currentRouter.use('/', currentSettingsRouter)

View File

@ -0,0 +1,97 @@
import fs from 'node:fs'
import { URL } from 'node:url'
import { randomUUID } from 'node:crypto'
import { Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import authenticateUser from '../../../../tools/plugins/authenticateUser.js'
import { fastifyErrors } from '../../../../models/utils.js'
import fastifyMultipart, { Multipart } from 'fastify-multipart'
import {
maximumImageSize,
supportedImageMimetype,
ROOT_URL
} from '../../../../tools/configurations'
import prisma from '../../../../tools/database/prisma.js'
const putServiceSchema: FastifySchema = {
description: 'Edit the current connected user logo',
tags: ['users'] as string[],
consumes: ['multipart/form-data'] as string[],
produces: ['application/json'] as string[],
security: [
{
bearerAuth: []
}
] as Array<{ [key: string]: [] }>,
response: {
200: Type.Object({
user: Type.Object({
logo: Type.String()
})
}),
400: fastifyErrors[400],
401: fastifyErrors[401],
403: fastifyErrors[403],
431: fastifyErrors[431],
500: fastifyErrors[500]
}
} as const
export const putCurrentUserLogo: FastifyPluginAsync = async (fastify) => {
await fastify.register(authenticateUser)
await fastify.register(fastifyMultipart)
fastify.route({
method: 'PUT',
url: '/users/current/logo',
schema: putServiceSchema,
handler: async (request, reply) => {
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
let files: Multipart[] = []
try {
files = await request.saveRequestFiles({
limits: {
files: 1,
fileSize: maximumImageSize * 1024 * 1024
}
})
} catch (error) {
throw fastify.httpErrors.requestHeaderFieldsTooLarge(
`body.logo should be less than ${maximumImageSize}mb.`
)
}
if (files.length !== 1) {
throw fastify.httpErrors.badRequest('You must upload at most one file.')
}
const image = files[0]
if (!supportedImageMimetype.includes(image.mimetype)) {
throw fastify.httpErrors.badRequest(
`The file must have a valid type (${supportedImageMimetype.join(
', '
)}).`
)
}
const splitedMimetype = image.mimetype.split('/')
const imageExtension = splitedMimetype[1]
const logoPath = `uploads/users/${randomUUID()}.${imageExtension}`
const logoURL = new URL(logoPath, ROOT_URL)
const logo = `/${logoPath}`
await fs.promises.copyFile(image.filepath, logoURL)
await prisma.user.update({
where: { id: request.user.current.id },
data: { logo }
})
reply.statusCode = 200
return {
user: {
logo
}
}
}
})
}

View File

@ -1,141 +1,136 @@
import { Request, Response, Router } from 'express'
import fileUpload from 'express-fileupload'
import { body, query } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { randomUUID } from 'node:crypto'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import {
commonErrorsMessages,
imageFileUploadOptions,
usersLogoPath
} from '../../../tools/configurations/constants'
import { alreadyUsedValidation } from '../../../tools/validations/alreadyUsedValidation'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { uploadImage } from '../../../tools/utils/uploadImage'
import { deleteEveryRefreshTokens } from '../__utils__/deleteEveryRefreshTokens'
import UserSetting from '../../../models/UserSetting'
import { sendEmail } from '../../../tools/email/sendEmail'
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid',
alreadyConnected: 'You are already connected with this email address'
},
name: {
alreadyConnected: 'You are already connected with this name'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import authenticateUser from '../../../tools/plugins/authenticateUser.js'
import { userCurrentSchema, userSchema } from '../../../models/User.js'
import { sendEmail } from '../../../tools/email/sendEmail.js'
import { HOST, PORT } from '../../../tools/configurations/index.js'
import { Language, Theme } from '../../../models/UserSettings.js'
const bodyPutServiceSchema = Type.Object({
name: Type.Optional(userSchema.name),
email: Type.Optional(userSchema.email),
status: Type.Optional(userSchema.status),
biography: Type.Optional(userSchema.biography),
website: Type.Optional(userSchema.website)
})
type BodyPutServiceSchemaType = Static<typeof bodyPutServiceSchema>
const queryPutCurrentUserSchema = Type.Object({
redirectURI: Type.Optional(Type.String({ format: 'uri-reference' }))
})
type QueryPutCurrentUserSchemaType = Static<typeof queryPutCurrentUserSchema>
const putServiceSchema: FastifySchema = {
description: 'Edit the current connected user information',
tags: ['users'] as string[],
security: [
{
bearerAuth: []
}
] as Array<{ [key: string]: [] }>,
body: bodyPutServiceSchema,
querystring: queryPutCurrentUserSchema,
response: {
200: userCurrentSchema,
400: fastifyErrors[400],
401: fastifyErrors[401],
403: fastifyErrors[403],
500: fastifyErrors[500]
}
}
} as const
export const putCurrentRouter = Router()
export const putCurrentUser: FastifyPluginAsync = async (fastify) => {
await fastify.register(authenticateUser)
putCurrentRouter.put(
'/users/current',
authenticateUser,
fileUpload(imageFileUploadOptions),
[
body('email')
.optional({ nullable: true })
.trim()
.isEmail()
.withMessage(errorsMessages.email.mustBeValid)
.custom(async (email: string, meta) => {
if (email === meta.req.user?.current.email) {
return await Promise.reject(
new Error(errorsMessages.email.alreadyConnected)
)
}
return await alreadyUsedValidation(User, 'email', email)
}),
body('name')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ max: 30, min: 3 })
.withMessage(
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
)
.custom(async (name: string, meta) => {
if (name === meta.req.user?.current.name) {
return await Promise.reject(
new Error(errorsMessages.name.alreadyConnected)
)
}
return await alreadyUsedValidation(User, 'name', name)
}),
body('biography')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ max: 160 })
.withMessage(
commonErrorsMessages.charactersLength('biography', { max: 160 })
),
body('status')
.optional({ nullable: true })
.trim()
.escape()
.isLength({ max: 100 })
.withMessage(
commonErrorsMessages.charactersLength('status', { max: 100 })
),
query('redirectURI').optional({ nullable: true }).trim()
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const { name, email, status, biography } = req.body as {
name?: string
email?: string
status?: string
biography?: string
}
const logo = req.files?.logo
const { redirectURI } = req.query as { redirectURI?: string }
const user = req.user.current
user.name = name ?? user.name
user.status = status ?? user.status
user.biography = biography ?? user.biography
const resultUpload = await uploadImage({
image: logo,
propertyName: 'logo',
oldImage: user.logo,
imagesPath: usersLogoPath.filePath
})
if (resultUpload != null) {
user.logo = `${usersLogoPath.name}/${resultUpload}`
}
if (email != null) {
user.email = email
if (req.user.currentStrategy === 'local') {
await deleteEveryRefreshTokens(user.id)
fastify.route<{
Body: BodyPutServiceSchemaType
Params: QueryPutCurrentUserSchemaType
}>({
method: 'PUT',
url: '/users/current',
schema: putServiceSchema,
handler: async (request, reply) => {
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
const tempToken = uuidv4()
user.tempToken = tempToken
user.isConfirmed = false
const userSettings = await UserSetting.findOne({
where: { userId: user.id }
const { name, email, status, biography, website } = request.body
const { redirectURI } = request.params
const userValidation = await prisma.user.findFirst({
where: {
OR: [{ email }, { name }],
AND: [{ id: { not: request.user.current.id } }]
}
})
const redirectQuery =
redirectURI != null ? `&redirectURI=${redirectURI}` : ''
await sendEmail({
type: 'confirm-email',
email,
url: `${process.env.API_BASE_URL}/users/confirmEmail?tempToken=${tempToken}${redirectQuery}`,
language: userSettings?.language,
theme: userSettings?.theme
if (userValidation != null) {
throw fastify.httpErrors.badRequest(
'body.email or body.name already taken.'
)
}
const settings = await prisma.userSetting.findFirst({
where: { userId: request.user.current.id }
})
if (settings == null) {
throw fastify.httpErrors.internalServerError()
}
const OAuths = await prisma.oAuth.findMany({
where: { userId: request.user.current.id }
})
const strategies = OAuths.map((oauth) => {
return oauth.provider
})
if (request.user.current.password != null) {
strategies.push('local')
}
if (email != null) {
await prisma.refreshToken.deleteMany({
where: {
userId: request.user.current.id
}
})
const temporaryToken = randomUUID()
const redirectQuery =
redirectURI != null ? `&redirectURI=${redirectURI}` : ''
await sendEmail({
type: 'confirm-email',
email,
url: `${request.protocol}://${HOST}:${PORT}/users/confirm-email?temporaryToken=${temporaryToken}${redirectQuery}`,
language: settings.language as Language,
theme: settings.theme as Theme
})
await prisma.user.update({
where: { id: request.user.current.id },
data: {
email,
temporaryToken,
isConfirmed: false
}
})
}
const user = await prisma.user.update({
where: { id: request.user.current.id },
data: {
name: name ?? request.user.current.name,
status: status ?? request.user.current.status,
biography: biography ?? request.user.current.biography,
website: website ?? request.user.current.website
}
})
reply.statusCode = 200
return {
user: {
...user,
settings,
currentStrategy: request.user.currentStrategy,
strategies
}
}
}
const userSaved = await user.save()
return res
.status(200)
.json({ user: userSaved, strategy: req.user.currentStrategy })
}
)
})
}

View File

@ -1,24 +0,0 @@
/users/current/settings:
put:
security:
- bearerAuth: []
tags:
- 'users'
summary: 'Edit the current connected user settings'
requestBody:
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/UserSettings'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- '200':
content:
application/json:
schema:
allOf:
- $ref: '#/definitions/UserSettingsObject'

View File

@ -1,66 +1,56 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
import application from '../../../../../application'
import { application } from '../../../../../application.js'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUserTest.js'
import { prismaMock } from '../../../../../__test__/setup.js'
import { userSettingsExample } from '../../../../../models/UserSettings.js'
describe('PUT /users/current/settings', () => {
it('should succeeds and edit theme, language and isPublicEmail', async () => {
const isPublicEmail = true
const theme = 'light'
const language = 'fr'
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current/settings')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ isPublicEmail, theme, language })
.expect(200)
expect(response.body.settings).not.toBeNull()
expect(response.body.settings.theme).toEqual(theme)
expect(response.body.settings.language).toEqual(language)
expect(response.body.settings.isPublicEmail).toEqual(isPublicEmail)
})
it('fails with unconfirmed account', async () => {
const userToken = await authenticateUserTest({
name: 'John',
email: 'contact@john.com',
shouldBeConfirmed: false
it('succeeds and edit the theme, language, isPublicEmail and isPublicGuilds', async () => {
const newSettings = {
theme: 'light',
language: 'fr',
isPublicEmail: true,
isPublicGuilds: true
}
prismaMock.userSetting.findFirst.mockResolvedValue(userSettingsExample)
prismaMock.userSetting.update.mockResolvedValue({
...userSettingsExample,
...newSettings
})
const response = await request(application)
.put('/users/current/settings')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(401)
expect(response.body.errors.length).toEqual(1)
})
it('fails with invalid theme', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current/settings')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ theme: 'random theme value' })
.expect(400)
expect(response.body.errors.length).toEqual(1)
const { accessToken } = await authenticateUserTest()
const response = await application.inject({
method: 'PUT',
url: '/users/current/settings',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: newSettings
})
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.settings.theme).toEqual(newSettings.theme)
expect(responseJson.settings.language).toEqual(newSettings.language)
expect(responseJson.settings.isPublicEmail).toEqual(
newSettings.isPublicEmail
)
expect(responseJson.settings.isPublicGuilds).toEqual(
newSettings.isPublicGuilds
)
})
it('fails with invalid language', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current/settings')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ language: 'random language value' })
.expect(400)
expect(response.body.errors.length).toEqual(1)
})
it('fails with invalid isPublicEmail', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.put('/users/current/settings')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ isPublicEmail: 'not a boolean value' })
.expect(400)
expect(response.body.errors.length).toEqual(1)
const newSettings = {
language: 'somerandomlanguage'
}
prismaMock.userSetting.findFirst.mockResolvedValue(userSettingsExample)
const { accessToken } = await authenticateUserTest()
const response = await application.inject({
method: 'PUT',
url: '/users/current/settings',
headers: {
authorization: `Bearer ${accessToken}`
},
payload: newSettings
})
expect(response.statusCode).toEqual(400)
})
})

View File

@ -1,7 +0,0 @@
import { Router } from 'express'
import { putCurrentSettingsRouter } from './put'
export const currentSettingsRouter = Router()
currentSettingsRouter.use('/', putCurrentSettingsRouter)

View File

@ -1,63 +1,71 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import { authenticateUser } from '../../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../../tools/middlewares/validateRequest'
import UserSetting, {
themes,
Theme,
languages,
Language
} from '../../../../models/UserSetting'
import { ForbiddenError } from '../../../../tools/errors/ForbiddenError'
import { NotFoundError } from '../../../../tools/errors/NotFoundError'
import { onlyPossibleValuesValidation } from '../../../../tools/validations/onlyPossibleValuesValidation'
import prisma from '../../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../../models/utils.js'
import authenticateUser from '../../../../tools/plugins/authenticateUser.js'
import { userSettingsSchema } from '../../../../models/UserSettings.js'
export const putCurrentSettingsRouter = Router()
const bodyPutServiceSchema = Type.Object({
theme: Type.Optional(userSettingsSchema.theme),
language: Type.Optional(userSettingsSchema.language),
isPublicEmail: Type.Optional(userSettingsSchema.isPublicEmail),
isPublicGuilds: Type.Optional(userSettingsSchema.isPublicGuilds)
})
putCurrentSettingsRouter.put(
'/users/current/settings',
authenticateUser,
[
body('isPublicEmail').optional({ nullable: true }).isBoolean(),
body('theme')
.optional({ nullable: true })
.trim()
.isString()
.custom(async (theme: Theme) => {
return await onlyPossibleValuesValidation([...themes], 'theme', theme)
}),
body('language')
.optional({ nullable: true })
.trim()
.isString()
.custom(async (language: Language) => {
return await onlyPossibleValuesValidation(
languages,
'language',
language
)
})
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
type BodyPutServiceSchemaType = Static<typeof bodyPutServiceSchema>
const putServiceSchema: FastifySchema = {
description: 'Edit the current connected user settings',
tags: ['users'] as string[],
security: [
{
bearerAuth: []
}
const { isPublicEmail, theme, language } = req.body as {
isPublicEmail?: boolean
theme?: Theme
language?: Language
}
const user = req.user.current
const settings = await UserSetting.findOne({ where: { id: user.id } })
if (settings == null) {
throw new NotFoundError()
}
settings.isPublicEmail = isPublicEmail ?? settings.isPublicEmail
settings.theme = theme ?? settings.theme
settings.language = language ?? settings.language
await settings.save()
return res.status(200).json({ settings })
] as Array<{ [key: string]: [] }>,
body: bodyPutServiceSchema,
response: {
200: Type.Object({
settings: Type.Object(userSettingsSchema)
}),
400: fastifyErrors[400],
401: fastifyErrors[401],
403: fastifyErrors[403],
500: fastifyErrors[500]
}
)
} as const
export const putCurrentUserSettings: FastifyPluginAsync = async (fastify) => {
await fastify.register(authenticateUser)
fastify.route<{
Body: BodyPutServiceSchemaType
}>({
method: 'PUT',
url: '/users/current/settings',
schema: putServiceSchema,
handler: async (request, reply) => {
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
const { theme, language, isPublicEmail, isPublicGuilds } = request.body
const settings = await prisma.userSetting.findFirst({
where: { userId: request.user.current.id }
})
if (settings == null) {
throw fastify.httpErrors.internalServerError()
}
const newSettings = await prisma.userSetting.update({
where: { id: request.user.current.id },
data: {
theme: theme ?? settings.theme,
language: language ?? settings.language,
isPublicEmail: isPublicEmail ?? settings.isPublicEmail,
isPublicGuilds: isPublicGuilds ?? settings.isPublicGuilds
}
})
reply.statusCode = 200
return { settings: newSettings }
}
})
}

View File

@ -1,26 +1,31 @@
import { Router } from 'express'
import { FastifyPluginAsync } from 'fastify'
import { confirmEmailRouter } from './confirmEmail/get'
import { currentRouter } from './current'
import { OAuth2Router } from './oauth2'
import { refreshTokenRouter } from './refreshToken/post'
import { resetPasswordRouter } from './resetPassword'
import { signinRouter } from './signin/post'
import { signoutRouter } from './signout'
import { signupRouter } from './signup/post'
import { usersGetByIdRouter } from './[userId]'
import { addLocalStrategyRouter } from './addLocalStrategy/post'
import { postSignupUser } from './signup/post.js'
import { getConfirmEmail } from './confirm-email/get.js'
import { postSigninUser } from './signin/post.js'
import { postSignoutUser } from './signout/post.js'
import { deleteSignoutUser } from './signout/delete.js'
import { postRefreshTokenUser } from './refresh-token/post.js'
import { putResetPasswordUser } from './reset-password/put.js'
import { postResetPasswordUser } from './reset-password/post.js'
import { getCurrentUser } from './current/get.js'
import { putCurrentUser } from './current/put.js'
import { putCurrentUserSettings } from './current/settings/put.js'
import { getUserById } from './[userId]/get.js'
import { putCurrentUserLogo } from './current/logo/put.js'
export const usersRouter = Router()
usersRouter.use('/', confirmEmailRouter)
usersRouter.use('/', currentRouter)
usersRouter.use('/', refreshTokenRouter)
usersRouter.use('/', resetPasswordRouter)
usersRouter.use('/', signinRouter)
usersRouter.use('/', signoutRouter)
usersRouter.use('/', signupRouter)
usersRouter.use('/', OAuth2Router)
usersRouter.use('/', usersGetByIdRouter)
usersRouter.use('/', signoutRouter)
usersRouter.use('/', addLocalStrategyRouter)
export const usersService: FastifyPluginAsync = async (fastify) => {
await fastify.register(postSignupUser)
await fastify.register(getConfirmEmail)
await fastify.register(postSigninUser)
await fastify.register(postSignoutUser)
await fastify.register(deleteSignoutUser)
await fastify.register(postRefreshTokenUser)
await fastify.register(putResetPasswordUser)
await fastify.register(postResetPasswordUser)
await fastify.register(getCurrentUser)
await fastify.register(putCurrentUser)
await fastify.register(putCurrentUserSettings)
await fastify.register(putCurrentUserLogo)
await fastify.register(getUserById)
}

View File

@ -1,24 +0,0 @@
/users/oauth2/{provider}:
delete:
security:
- bearerAuth: []
tags:
- 'users'
summary: 'Allows a user to delete a strategy of authentication (except local).'
parameters:
- name: 'provider'
in: 'path'
required: true
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- $ref: '#/definitions/ForbiddenError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
message:
type: 'string'

View File

@ -1,55 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import OAuth from '../../../../models/OAuth'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import { errorsMessages } from '../delete'
import { GOOGLE_PROVIDER, googleStrategy } from '../google'
describe('DELETE /users/oauth2/:provider', () => {
it('succeeds with valid provider', async () => {
const userToken = await authenticateUserTest()
const oauth = await OAuth.create({
provider: 'google',
providerId: 'randomid',
userId: userToken.userId
})
await request(application)
.delete(`/users/oauth2/${oauth.provider}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(200)
})
it('fails with invalid provider', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.delete('/users/oauth2/google')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.provider.notUsed])
)
})
it('fails with the only way to authenticate', async () => {
const userToken = await googleStrategy.callbackSignin({
id: 'randomproviderid',
name: 'john'
})
const response = await request(application)
.delete(`/users/oauth2/${GOOGLE_PROVIDER}`)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(1)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.provider.onlyWayToAuthenticate])
)
})
})

View File

@ -1,81 +0,0 @@
import request from 'supertest'
import axios from 'axios'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import { authorizedRedirectDomains } from '../../../../tools/configurations/constants'
import { DISCORD_PROVIDER } from '../discord'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe(`/users/oauth2/${DISCORD_PROVIDER}`, () => {
test(`GET /users/oauth2/${DISCORD_PROVIDER}/add-strategy`, async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.get(
`/users/oauth2/${DISCORD_PROVIDER}/add-strategy?redirectURI=${authorizedRedirectDomains[0]}`
)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(200)
expect(typeof response.body).toEqual('string')
})
test(`GET /users/oauth2/${DISCORD_PROVIDER}/callback-add-strategy`, async () => {
mockedAxios.get.mockResolvedValue({
data: {
id: 'randomid',
username: 'John',
discriminator: '1234'
}
})
mockedAxios.post.mockResolvedValue({
data: {
token_type: 'Bearer',
access_token: 'randomtoken'
}
})
const userToken = await authenticateUserTest()
await request(application)
.get(
`/users/oauth2/${DISCORD_PROVIDER}/callback-add-strategy?redirectURI=${authorizedRedirectDomains[0]}&code=randomtokencode&state=${userToken.accessToken}`
)
.send()
.expect(302)
jest.resetAllMocks()
})
test(`GET /users/oauth2/${DISCORD_PROVIDER}/signin`, async () => {
const response = await request(application)
.get(
`/users/oauth2/${DISCORD_PROVIDER}/signin?redirectURI=${authorizedRedirectDomains[0]}`
)
.send()
.expect(200)
expect(typeof response.body).toEqual('string')
})
test(`GET /users/oauth2/${DISCORD_PROVIDER}/callback`, async () => {
mockedAxios.get.mockResolvedValue({
data: {
id: 'randomid',
username: 'John',
discriminator: '1234'
}
})
mockedAxios.post.mockResolvedValue({
data: {
token_type: 'Bearer',
access_token: 'randomtoken'
}
})
await request(application)
.get(
`/users/oauth2/${DISCORD_PROVIDER}/callback?redirectURI=${authorizedRedirectDomains[0]}&code=randomtokencode`
)
.send()
.expect(302)
jest.resetAllMocks()
})
})

View File

@ -1,77 +0,0 @@
import request from 'supertest'
import axios from 'axios'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import { authorizedRedirectDomains } from '../../../../tools/configurations/constants'
import { GITHUB_PROVIDER } from '../github'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe(`/users/oauth2/${GITHUB_PROVIDER}`, () => {
test(`GET /users/oauth2/${GITHUB_PROVIDER}/add-strategy`, async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.get(
`/users/oauth2/${GITHUB_PROVIDER}/add-strategy?redirectURI=${authorizedRedirectDomains[0]}`
)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(200)
expect(typeof response.body).toEqual('string')
})
test(`GET /users/oauth2/${GITHUB_PROVIDER}/callback-add-strategy`, async () => {
mockedAxios.get.mockResolvedValue({
data: {
id: 12,
name: 'John'
}
})
mockedAxios.post.mockResolvedValue({
data: {
access_token: 'randomtoken'
}
})
const userToken = await authenticateUserTest()
await request(application)
.get(
`/users/oauth2/${GITHUB_PROVIDER}/callback-add-strategy?redirectURI=${authorizedRedirectDomains[0]}&code=randomtokencode&state=${userToken.accessToken}`
)
.send()
.expect(302)
jest.resetAllMocks()
})
test(`GET /users/oauth2/${GITHUB_PROVIDER}/signin`, async () => {
const response = await request(application)
.get(
`/users/oauth2/${GITHUB_PROVIDER}/signin?redirectURI=${authorizedRedirectDomains[0]}`
)
.send()
.expect(200)
expect(typeof response.body).toEqual('string')
})
test(`GET /users/oauth2/${GITHUB_PROVIDER}/callback`, async () => {
mockedAxios.get.mockResolvedValue({
data: {
id: 12,
name: 'John'
}
})
mockedAxios.post.mockResolvedValue({
data: {
access_token: 'randomtoken'
}
})
await request(application)
.get(
`/users/oauth2/${GITHUB_PROVIDER}/callback?redirectURI=${authorizedRedirectDomains[0]}&code=randomtokencode`
)
.send()
.expect(302)
jest.resetAllMocks()
})
})

View File

@ -1,79 +0,0 @@
import request from 'supertest'
import axios from 'axios'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import { authorizedRedirectDomains } from '../../../../tools/configurations/constants'
import { GOOGLE_PROVIDER } from '../google'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
describe(`/users/oauth2/${GOOGLE_PROVIDER}`, () => {
test(`GET /users/oauth2/${GOOGLE_PROVIDER}/add-strategy`, async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.get(
`/users/oauth2/${GOOGLE_PROVIDER}/add-strategy?redirectURI=${authorizedRedirectDomains[0]}`
)
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(200)
expect(typeof response.body).toEqual('string')
})
test(`GET /users/oauth2/${GOOGLE_PROVIDER}/callback-add-strategy`, async () => {
mockedAxios.get.mockResolvedValue({
data: {
id: 'randomid',
name: 'John'
}
})
mockedAxios.post.mockResolvedValue({
data: {
token_type: 'Bearer',
access_token: 'randomtoken'
}
})
const userToken = await authenticateUserTest()
await request(application)
.get(
`/users/oauth2/${GOOGLE_PROVIDER}/callback-add-strategy?redirectURI=${authorizedRedirectDomains[0]}&code=randomtokencode&state=${userToken.accessToken}`
)
.send()
.expect(302)
jest.resetAllMocks()
})
test(`GET /users/oauth2/${GOOGLE_PROVIDER}/signin`, async () => {
const response = await request(application)
.get(
`/users/oauth2/${GOOGLE_PROVIDER}/signin?redirectURI=${authorizedRedirectDomains[0]}`
)
.send()
.expect(200)
expect(typeof response.body).toEqual('string')
})
test(`GET /users/oauth2/${GOOGLE_PROVIDER}/callback`, async () => {
mockedAxios.get.mockResolvedValue({
data: {
id: 'randomid',
name: 'John'
}
})
mockedAxios.post.mockResolvedValue({
data: {
token_type: 'Bearer',
access_token: 'randomtoken'
}
})
await request(application)
.get(
`/users/oauth2/${GOOGLE_PROVIDER}/callback?redirectURI=${authorizedRedirectDomains[0]}&code=randomtokencode`
)
.send()
.expect(302)
jest.resetAllMocks()
})
})

View File

@ -1,68 +0,0 @@
import { Request, Response, Router } from 'express'
import { param } from 'express-validator'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import OAuth, {
AuthenticationStrategy,
ProviderOAuth,
providers
} from '../../../models/OAuth'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { onlyPossibleValuesValidation } from '../../../tools/validations/onlyPossibleValuesValidation'
export const errorsMessages = {
provider: {
notUsed: 'You are not using this provider',
onlyWayToAuthenticate: "You can't delete your only way to authenticate"
}
}
export const deleteOAuthStrategy = Router()
deleteOAuthStrategy.delete(
'/users/oauth2/:provider',
authenticateUser,
[
param('provider')
.trim()
.isString()
.custom(async (provider: ProviderOAuth) => {
return await onlyPossibleValuesValidation(
providers,
'provider',
provider
)
})
],
validateRequest,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const user = req.user.current
const { provider } = req.params as { provider: ProviderOAuth }
const OAuths = await OAuth.findAll({
where: { userId: user.id }
})
const strategies: AuthenticationStrategy[] = OAuths.map((oauth) => {
return oauth.provider
})
if (req.user.current.password != null) {
strategies.push('local')
}
const oauthProvider = OAuths.find((oauth) => oauth.provider === provider)
if (oauthProvider == null) {
throw new BadRequestError(errorsMessages.provider.notUsed)
}
const hasOthersWayToAuthenticate = strategies.length >= 2
if (!hasOthersWayToAuthenticate) {
throw new BadRequestError(errorsMessages.provider.onlyWayToAuthenticate)
}
await oauthProvider.destroy()
return res.status(200).json({
message: `Success, you will not be able to login with ${oauthProvider.provider} anymore.`
})
}
)

View File

@ -1,164 +0,0 @@
import axios from 'axios'
import { Request, Response, Router } from 'express'
import { query } from 'express-validator'
import querystring from 'querystring'
import {
authenticateUser,
getUserWithBearerToken
} from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { buildQueryURL } from '../__utils__/buildQueryURL'
import { isValidRedirectURIValidation } from '../../../tools/validations/isValidRedirectURIValidation'
import { OAuthStrategy } from '../__utils__/OAuthStrategy'
export const DISCORD_PROVIDER = 'discord'
export const DISCORD_BASE_URL = 'https://discordapp.com/api/v6'
export const discordStrategy = new OAuthStrategy(DISCORD_PROVIDER)
interface DiscordUser {
id: string
username: string
discriminator: string
avatar?: string
locale?: string
}
interface DiscordTokens {
access_token: string
token_type: string
expires_in: number
refresh_token: string
scope: string
}
const getDiscordUserData = async (
code: string,
redirectURI: string
): Promise<DiscordUser> => {
const { data: tokens } = await axios.post<DiscordTokens>(
`${DISCORD_BASE_URL}/oauth2/token`,
querystring.stringify({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: redirectURI,
scope: 'identify'
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
const { data: discordUser } = await axios.get<DiscordUser>(
`${DISCORD_BASE_URL}/users/@me`,
{
headers: {
Authorization: `${tokens.token_type} ${tokens.access_token}`
}
}
)
return discordUser
}
export const discordRouter = Router()
discordRouter.get(
`/users/oauth2/${DISCORD_PROVIDER}/add-strategy`,
authenticateUser,
[
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
(req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const { redirectURI } = req.query as { redirectURI: string }
const redirectCallback = `${process.env.API_BASE_URL}/users/oauth2/${DISCORD_PROVIDER}/callback-add-strategy?redirectURI=${redirectURI}`
const url = `${DISCORD_BASE_URL}/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=identify&response_type=code&state=${req.user.accessToken}&redirect_uri=${redirectCallback}`
return res.json(url)
}
)
discordRouter.get(
`/users/oauth2/${DISCORD_PROVIDER}/callback-add-strategy`,
[
query('code').notEmpty(),
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation),
query('state')
.notEmpty()
.trim()
],
validateRequest,
async (req: Request, res: Response) => {
const { code, redirectURI, state: accessToken } = req.query as {
code: string
redirectURI: string
state: string
}
const userRequest = await getUserWithBearerToken(`Bearer ${accessToken}`)
const discordUser = await getDiscordUserData(
code,
`${process.env.API_BASE_URL}/users/oauth2/${DISCORD_PROVIDER}/callback-add-strategy?redirectURI=${redirectURI}`
)
const message = await discordStrategy.callbackAddStrategy(
{ name: discordUser.username, id: discordUser.id },
userRequest
)
return res.redirect(buildQueryURL(redirectURI, { message }))
}
)
discordRouter.get(
`/users/oauth2/${DISCORD_PROVIDER}/signin`,
[
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
(req: Request, res: Response) => {
const { redirectURI } = req.query as { redirectURI: string }
const redirectCallback = `${process.env.API_BASE_URL}/users/oauth2/${DISCORD_PROVIDER}/callback?redirectURI=${redirectURI}`
const url = `${DISCORD_BASE_URL}/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=identify&response_type=code&redirect_uri=${redirectCallback}`
return res.json(url)
}
)
discordRouter.get(
`/users/oauth2/${DISCORD_PROVIDER}/callback`,
[
query('code').notEmpty(),
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
async (req: Request, res: Response) => {
const { code, redirectURI } = req.query as {
code: string
redirectURI: string
}
const discordUser = await getDiscordUserData(
code,
`${process.env.API_BASE_URL}/users/oauth2/${DISCORD_PROVIDER}/callback?redirectURI=${redirectURI}`
)
const responseJWT = await discordStrategy.callbackSignin({
name: discordUser.username,
id: discordUser.id
})
return res.redirect(buildQueryURL(redirectURI, responseJWT))
}
)

View File

@ -1,161 +0,0 @@
import axios from 'axios'
import { Request, Response, Router } from 'express'
import { query } from 'express-validator'
import querystring from 'querystring'
import {
authenticateUser,
getUserWithBearerToken
} from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { buildQueryURL } from '../__utils__/buildQueryURL'
import { isValidRedirectURIValidation } from '../../../tools/validations/isValidRedirectURIValidation'
import { OAuthStrategy } from '../__utils__/OAuthStrategy'
interface GitHubUser {
login: string
id: number
name: string
avatar_url: string
}
interface GitHubTokens {
access_token: string
scope: string
token_type: string
}
export const GITHUB_PROVIDER = 'github'
export const GITHUB_BASE_URL = 'https://github.com'
export const GITHUB_API_BASE_URL = 'https://api.github.com'
export const githubStrategy = new OAuthStrategy(GITHUB_PROVIDER)
const getGitHubUserData = async (
code: string,
redirectURI: string
): Promise<GitHubUser> => {
const { data: token } = await axios.post<GitHubTokens>(
`${GITHUB_BASE_URL}/login/oauth/access_token`,
querystring.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: redirectURI
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
}
)
const { data: githubUser } = await axios.get<GitHubUser>(
`${GITHUB_API_BASE_URL}/user`,
{
headers: {
Authorization: `token ${token.access_token}`
}
}
)
return githubUser
}
export const githubRouter = Router()
githubRouter.get(
`/users/oauth2/${GITHUB_PROVIDER}/add-strategy`,
authenticateUser,
[
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
(req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const { redirectURI } = req.query as { redirectURI: string }
const redirectCallback = `${process.env.API_BASE_URL}/users/oauth2/${GITHUB_PROVIDER}/callback-add-strategy?redirectURI=${redirectURI}`
const url = `${GITHUB_BASE_URL}/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&state=${req.user.accessToken}&redirect_uri=${redirectCallback}`
return res.json(url)
}
)
githubRouter.get(
`/users/oauth2/${GITHUB_PROVIDER}/callback-add-strategy`,
[
query('code').notEmpty(),
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation),
query('state')
.notEmpty()
.trim()
],
validateRequest,
async (req: Request, res: Response) => {
const { code, redirectURI, state: accessToken } = req.query as {
code: string
redirectURI: string
state: string
}
const userRequest = await getUserWithBearerToken(`Bearer ${accessToken}`)
const githubUser = await getGitHubUserData(
code,
`${process.env.API_BASE_URL}/users/oauth2/${GITHUB_PROVIDER}/callback-add-strategy?redirectURI=${redirectURI}`
)
const message = await githubStrategy.callbackAddStrategy(
{ name: githubUser.name, id: githubUser.id },
userRequest
)
return res.redirect(buildQueryURL(redirectURI, { message }))
}
)
githubRouter.get(
`/users/oauth2/${GITHUB_PROVIDER}/signin`,
[
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
(req: Request, res: Response) => {
const { redirectURI } = req.query as { redirectURI: string }
const redirectCallback = `${process.env.API_BASE_URL}/users/oauth2/${GITHUB_PROVIDER}/callback?redirectURI=${redirectURI}`
const url = `${GITHUB_BASE_URL}/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&redirect_uri=${redirectCallback}`
return res.json(url)
}
)
githubRouter.get(
`/users/oauth2/${GITHUB_PROVIDER}/callback`,
[
query('code').notEmpty(),
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
async (req: Request, res: Response) => {
const { code, redirectURI } = req.query as {
code: string
redirectURI: string
}
const githubUser = await getGitHubUserData(
code,
`${process.env.API_BASE_URL}/users/oauth2/${GITHUB_PROVIDER}/callback?redirectURI=${redirectURI}`
)
const responseJWT = await githubStrategy.callbackSignin({
name: githubUser.name,
id: githubUser.id
})
return res.redirect(buildQueryURL(redirectURI, responseJWT))
}
)

View File

@ -1,162 +0,0 @@
import axios from 'axios'
import { Request, Response, Router } from 'express'
import { query } from 'express-validator'
import querystring from 'querystring'
import {
authenticateUser,
getUserWithBearerToken
} from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { buildQueryURL } from '../__utils__/buildQueryURL'
import { isValidRedirectURIValidation } from '../../../tools/validations/isValidRedirectURIValidation'
import { OAuthStrategy } from '../__utils__/OAuthStrategy'
interface GoogleUser {
id: string
name: string
given_name: string
link: string
picture: string
locale: string
}
interface GoogleTokens {
access_token: string
expires_in: number
token_type: string
scope: string
refresh_token?: string
}
export const GOOGLE_PROVIDER = 'google'
export const GOOGLE_BASE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
export const GOOGLE_OAUTH2_TOKEN = 'https://oauth2.googleapis.com/token'
export const GOOGLE_USERINFO = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'
export const googleStrategy = new OAuthStrategy(GOOGLE_PROVIDER)
const getGoogleUserData = async (
code: string,
redirectURI: string
): Promise<GoogleUser> => {
const { data: token } = await axios.post<GoogleTokens>(
GOOGLE_OAUTH2_TOKEN,
querystring.stringify({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code,
redirect_uri: redirectURI,
grant_type: 'authorization_code'
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
}
}
)
const { data: googleUser } = await axios.get<GoogleUser>(
`${GOOGLE_USERINFO}&access_token=${token.access_token}`
)
return googleUser
}
export const googleRouter = Router()
googleRouter.get(
`/users/oauth2/${GOOGLE_PROVIDER}/add-strategy`,
authenticateUser,
[
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
(req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
const { redirectURI } = req.query as { redirectURI: string }
const redirectCallback = `${process.env.API_BASE_URL}/users/oauth2/${GOOGLE_PROVIDER}/callback-add-strategy?redirectURI=${redirectURI}`
const url = `${GOOGLE_BASE_URL}?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${redirectCallback}&response_type=code&scope=profile&access_type=online&state=${req.user.accessToken}`
return res.json(url)
}
)
googleRouter.get(
`/users/oauth2/${GOOGLE_PROVIDER}/callback-add-strategy`,
[
query('code').notEmpty(),
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation),
query('state')
.notEmpty()
.trim()
],
validateRequest,
async (req: Request, res: Response) => {
const { code, redirectURI, state: accessToken } = req.query as {
code: string
redirectURI: string
state: string
}
const userRequest = await getUserWithBearerToken(`Bearer ${accessToken}`)
const googleUser = await getGoogleUserData(
code,
`${process.env.API_BASE_URL}/users/oauth2/${GOOGLE_PROVIDER}/callback-add-strategy?redirectURI=${redirectURI}`
)
const message = await googleStrategy.callbackAddStrategy(
{ name: googleUser.name, id: googleUser.id },
userRequest
)
return res.redirect(buildQueryURL(redirectURI, { message }))
}
)
googleRouter.get(
`/users/oauth2/${GOOGLE_PROVIDER}/signin`,
[
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
(req: Request, res: Response) => {
const { redirectURI } = req.query as { redirectURI: string }
const redirectCallback = `${process.env.API_BASE_URL}/users/oauth2/${GOOGLE_PROVIDER}/callback?redirectURI=${redirectURI}`
const url = `${GOOGLE_BASE_URL}?client_id=${process.env.GOOGLE_CLIENT_ID}&redirect_uri=${redirectCallback}&response_type=code&scope=profile&access_type=online`
return res.json(url)
}
)
googleRouter.get(
`/users/oauth2/${GOOGLE_PROVIDER}/callback`,
[
query('code').notEmpty(),
query('redirectURI')
.notEmpty()
.trim()
.custom(isValidRedirectURIValidation)
],
validateRequest,
async (req: Request, res: Response) => {
const { code, redirectURI } = req.query as {
code: string
redirectURI: string
}
const googleUser = await getGoogleUserData(
code,
`${process.env.API_BASE_URL}/users/oauth2/${GOOGLE_PROVIDER}/callback?redirectURI=${redirectURI}`
)
const responseJWT = await googleStrategy.callbackSignin({
name: googleUser.name,
id: googleUser.id
})
return res.redirect(buildQueryURL(redirectURI, responseJWT))
}
)

View File

@ -1,15 +0,0 @@
import { Router } from 'express'
import { deleteOAuthStrategy } from './delete'
import { discordRouter } from './discord'
import { githubRouter } from './github'
import { googleRouter } from './google'
const OAuth2Router = Router()
OAuth2Router.use('/', discordRouter)
OAuth2Router.use('/', githubRouter)
OAuth2Router.use('/', googleRouter)
OAuth2Router.use('/', deleteOAuthStrategy)
export { OAuth2Router }

View File

@ -0,0 +1,46 @@
import { application } from '../../../../application.js'
import { refreshTokenExample } from '../../../../models/RefreshToken.js'
import { expiresIn } from '../../../../tools/utils/jwtToken.js'
import { prismaMock } from '../../../../__test__/setup.js'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUserTest.js'
describe('POST /users/refresh-token', () => {
it('succeeds', async () => {
const { refreshToken } = await authenticateUserTest()
prismaMock.refreshToken.findFirst.mockResolvedValue({
...refreshTokenExample,
id: 1,
token: refreshToken
})
const response = await application.inject({
method: 'POST',
url: '/users/refresh-token',
payload: { refreshToken }
})
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.type).toEqual('Bearer')
expect(responseJson.expiresIn).toEqual(expiresIn)
expect(typeof responseJson.accessToken).toEqual('string')
})
it('fails with refreshToken noty saved in database', async () => {
const response = await application.inject({
method: 'POST',
url: '/users/refresh-token',
payload: { refreshToken: 'somerandomtoken' }
})
expect(response.statusCode).toEqual(403)
})
it('fails with invalid jwt refreshToken', async () => {
const { refreshToken } = await authenticateUserTest()
prismaMock.refreshToken.findFirst.mockResolvedValue(refreshTokenExample)
const response = await application.inject({
method: 'POST',
url: '/users/refresh-token',
payload: { refreshToken }
})
expect(response.statusCode).toEqual(403)
})
})

View File

@ -0,0 +1,72 @@
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import jwt from 'jsonwebtoken'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import {
generateAccessToken,
jwtSchema,
expiresIn
} from '../../../tools/utils/jwtToken.js'
import { UserJWT } from '../../../models/User.js'
import { JWT_REFRESH_SECRET } from '../../../tools/configurations/index.js'
const bodyPostRefreshTokenSchema = Type.Object({
refreshToken: jwtSchema.refreshToken
})
type BodyPostRefreshTokenSchemaType = Static<typeof bodyPostRefreshTokenSchema>
const postRefreshTokenSchema: FastifySchema = {
description: 'Refresh the accessToken of the user',
tags: ['users'] as string[],
body: bodyPostRefreshTokenSchema,
response: {
200: Type.Object({
accessToken: jwtSchema.accessToken,
expiresIn: jwtSchema.expiresIn,
type: jwtSchema.type
}),
400: fastifyErrors[400],
403: fastifyErrors[403],
500: fastifyErrors[500]
}
} as const
export const postRefreshTokenUser: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Body: BodyPostRefreshTokenSchemaType
}>({
method: 'POST',
url: '/users/refresh-token',
schema: postRefreshTokenSchema,
handler: async (request, reply) => {
const { refreshToken } = request.body
const foundRefreshToken = await prisma.refreshToken.findFirst({
where: { token: refreshToken }
})
if (foundRefreshToken == null) {
throw fastify.httpErrors.forbidden()
}
try {
const userJWT = jwt.verify(
foundRefreshToken.token,
JWT_REFRESH_SECRET
) as UserJWT
const accessToken = generateAccessToken({
id: userJWT.id,
currentStrategy: 'local'
})
reply.statusCode = 200
return {
accessToken,
expiresIn,
type: 'Bearer'
}
} catch {
throw fastify.httpErrors.forbidden()
}
}
})
}

View File

@ -1,24 +0,0 @@
/users/refreshToken:
post:
tags:
- 'users'
summary: 'Refresh the accessToken of the user'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
refreshToken:
type: 'string'
required:
- 'refreshToken'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- $ref: '#/definitions/UnauthorizedError'
- '200':
content:
application/json:
schema:
$ref: '#/definitions/AccessTokenResponse'

View File

@ -1,26 +0,0 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
describe('POST /users/refreshToken', () => {
it('succeeds and generate a new accessToken with a valid refreshToken', async () => {
const userToken = await authenticateUserTest()
const response = await request(application)
.post('/users/refreshToken')
.send({
refreshToken: userToken.refreshToken
})
.expect(200)
expect(response.body.accessToken).not.toBeNull()
})
it('fails with invalid refreshToken', async () => {
await request(application)
.post('/users/refreshToken')
.send({
refreshToken: 'invalidtoken'
})
.expect(401)
})
})

View File

@ -1,55 +0,0 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import jwt from 'jsonwebtoken'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import RefreshToken from '../../../models/RefreshToken'
import { UserJWT } from '../../../models/User'
import {
expiresIn,
generateAccessToken,
ResponseJWT
} from '../../../tools/configurations/jwtToken'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { UnauthorizedError } from '../../../tools/errors/UnauthorizedError'
export const refreshTokenRouter = Router()
refreshTokenRouter.post(
'/users/refreshToken',
[
body('refreshToken')
.trim()
.notEmpty()
],
validateRequest,
async (req: Request, res: Response) => {
const { refreshToken } = req.body as { refreshToken: string }
const foundRefreshToken = await RefreshToken.findOne({
where: { token: refreshToken }
})
if (foundRefreshToken == null) {
throw new UnauthorizedError()
}
jwt.verify(
foundRefreshToken.token,
process.env.JWT_REFRESH_SECRET,
(error, user) => {
if (error != null) {
throw new ForbiddenError()
}
const userJWT = user as UserJWT
const accessToken = generateAccessToken({
id: userJWT.id,
currentStrategy: userJWT.currentStrategy
})
const responseJWT: ResponseJWT = {
accessToken,
expiresIn,
type: 'Bearer'
}
return res.status(200).json(responseJWT)
}
)
}
)

View File

@ -0,0 +1,66 @@
import ms from 'ms'
import { application } from '../../../../application.js'
import { userExample } from '../../../../models/User.js'
import { userSettingsExample } from '../../../../models/UserSettings.js'
import { prismaMock } from '../../../../__test__/setup.js'
describe('POST /users/reset-password', () => {
it('succeeds', async () => {
prismaMock.user.findUnique.mockResolvedValue(userExample)
prismaMock.userSetting.findFirst.mockResolvedValue(userSettingsExample)
const response = await application.inject({
method: 'POST',
url: '/users/reset-password?redirectURI=https://redirecturi.com',
payload: { email: userExample.email }
})
expect(response.statusCode).toEqual(200)
})
it("fails with email that doesn't exist", async () => {
const response = await application.inject({
method: 'POST',
url: '/users/reset-password?redirectURI=https://redirecturi.com',
payload: { email: userExample.email }
})
expect(response.statusCode).toEqual(400)
})
it('fails with unconfirmed account', async () => {
prismaMock.user.findUnique.mockResolvedValue({
...userExample,
isConfirmed: false
})
const response = await application.inject({
method: 'POST',
url: '/users/reset-password?redirectURI=https://redirecturi.com',
payload: { email: userExample.email }
})
expect(response.statusCode).toEqual(400)
})
it("fails if userSettings doenst' exist", async () => {
prismaMock.user.findUnique.mockResolvedValue(userExample)
prismaMock.userSetting.findFirst.mockResolvedValue(null)
const response = await application.inject({
method: 'POST',
url: '/users/reset-password?redirectURI=https://redirecturi.com',
payload: { email: userExample.email }
})
expect(response.statusCode).toEqual(400)
})
it('fails with a request already in progress', async () => {
prismaMock.user.findUnique.mockResolvedValue({
...userExample,
temporaryExpirationToken: new Date(Date.now() + ms('1 hour'))
})
prismaMock.userSetting.findFirst.mockResolvedValue(userSettingsExample)
const response = await application.inject({
method: 'POST',
url: '/users/reset-password?redirectURI=https://redirecturi.com',
payload: { email: userExample.email }
})
expect(response.statusCode).toEqual(400)
})
})

View File

@ -0,0 +1,36 @@
import ms from 'ms'
import { application } from '../../../../application.js'
import { userExample } from '../../../../models/User.js'
import { prismaMock } from '../../../../__test__/setup.js'
describe('PUT /users/reset-password', () => {
it('succeeds', async () => {
prismaMock.user.findFirst.mockResolvedValue({
...userExample,
temporaryExpirationToken: new Date(Date.now() + ms('1 hour'))
})
const response = await application.inject({
method: 'PUT',
url: '/users/reset-password',
payload: {
password: 'new password',
temporaryToken: userExample.temporaryToken
}
})
expect(response.statusCode).toEqual(200)
})
it('fails with expired temporaryToken', async () => {
prismaMock.user.findFirst.mockResolvedValue(userExample)
const response = await application.inject({
method: 'PUT',
url: '/users/reset-password',
payload: {
password: 'new password',
temporaryToken: userExample.temporaryToken
}
})
expect(response.statusCode).toEqual(400)
})
})

View File

@ -0,0 +1,100 @@
import { randomUUID } from 'node:crypto'
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import ms from 'ms'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import { userSchema } from '../../../models/User.js'
import { sendEmail } from '../../../tools/email/sendEmail.js'
import { Language, Theme } from '../../../models/UserSettings.js'
const queryPostResetPasswordSchema = Type.Object({
redirectURI: Type.String({ format: 'uri-reference' })
})
type QueryPostResetPasswordSchemaType = Static<
typeof queryPostResetPasswordSchema
>
const bodyPostResetPasswordSchema = Type.Object({
email: userSchema.email
})
type BodyPostResetPasswordSchemaType = Static<
typeof bodyPostResetPasswordSchema
>
const postResetPasswordSchema: FastifySchema = {
description: 'Request a password-reset change',
tags: ['users'] as string[],
body: bodyPostResetPasswordSchema,
querystring: queryPostResetPasswordSchema,
response: {
200: Type.String(),
400: fastifyErrors[400],
500: fastifyErrors[500]
}
} as const
export const postResetPasswordUser: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Body: BodyPostResetPasswordSchemaType
Querystring: QueryPostResetPasswordSchemaType
}>({
method: 'POST',
url: '/users/reset-password',
schema: postResetPasswordSchema,
handler: async (request, reply) => {
const { email } = request.body
const { redirectURI } = request.query
const user = await prisma.user.findUnique({
where: {
email
}
})
if (user == null) {
throw fastify.httpErrors.badRequest("Email address doesn't exist")
}
if (!user.isConfirmed) {
throw fastify.httpErrors.badRequest(
'You should have a confirmed account, please check your email and follow the instructions to verify your account'
)
}
const isValidTemporaryToken =
user.temporaryExpirationToken != null &&
user.temporaryExpirationToken.getTime() > Date.now()
if (user.temporaryToken != null && isValidTemporaryToken) {
throw fastify.httpErrors.badRequest(
'A request to reset-password is already in progress'
)
}
const temporaryToken = randomUUID()
await prisma.user.update({
where: {
id: user.id
},
data: {
temporaryExpirationToken: new Date(Date.now() + ms('1 hour')),
temporaryToken
}
})
const userSettings = await prisma.userSetting.findFirst({
where: { userId: user.id }
})
if (userSettings == null) {
throw fastify.httpErrors.badRequest()
}
await sendEmail({
type: 'reset-password',
email,
url: `${redirectURI}?temporaryToken=${temporaryToken}`,
language: userSettings.language as Language,
theme: userSettings.theme as Theme
})
reply.statusCode = 200
return 'Password-reset request successful, please check your emails!'
}
})
}

View File

@ -0,0 +1,59 @@
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import bcrypt from 'bcryptjs'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import { userSchema } from '../../../models/User.js'
const bodyPutResetPasswordSchema = Type.Object({
password: userSchema.password,
temporaryToken: userSchema.temporaryToken
})
type BodyPutResetPasswordSchemaType = Static<typeof bodyPutResetPasswordSchema>
const putResetPasswordSchema: FastifySchema = {
description:
'Change the user password if the provided temporaryToken (sent in the email of POST /users/reset-password) is correct.',
tags: ['users'] as string[],
body: bodyPutResetPasswordSchema,
response: {
200: Type.String(),
400: fastifyErrors[400],
500: fastifyErrors[500]
}
} as const
export const putResetPasswordUser: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Body: BodyPutResetPasswordSchemaType
}>({
method: 'PUT',
url: '/users/reset-password',
schema: putResetPasswordSchema,
handler: async (request, reply) => {
const { password, temporaryToken } = request.body
const user = await prisma.user.findFirst({ where: { temporaryToken } })
const isValidTemporaryToken =
user?.temporaryExpirationToken != null &&
user.temporaryExpirationToken.getTime() > Date.now()
if (user == null || !isValidTemporaryToken) {
throw fastify.httpErrors.badRequest('"tempToken" is invalid')
}
const hashedPassword = await bcrypt.hash(password, 12)
await prisma.user.update({
where: {
id: user.id
},
data: {
password: hashedPassword,
temporaryToken: null,
temporaryExpirationToken: null
}
})
reply.statusCode = 200
return 'The new password has been saved!'
}
})
}

View File

@ -1,37 +0,0 @@
/users/resetPassword:
post:
tags:
- 'users'
summary: 'Request a password-reset change'
description: 'Allows a user to reset his password, if he forgets thanks to his email address.'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
email:
type: 'string'
format: 'email'
required:
- 'email'
parameters:
- name: 'redirectURI'
description: 'The redirect URI to redirect the user when he clicks on the button of the email, so he can change his password with a form on the frontend.'
in: 'query'
required: true
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
message:
type: 'string'
enum:
[
'Password-reset request successful, please check your emails!'
]

View File

@ -1,33 +0,0 @@
/users/resetPassword:
put:
tags:
- 'users'
summary: 'Change the user password'
description: 'Change the user password if the provided tempToken (sent in the email of POST /users/resetPassword) is correct.'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
password:
type: 'string'
format: 'password'
example: 'password'
tempToken:
type: 'string'
required:
- 'password'
- 'tempToken'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- '200':
content:
application/json:
schema:
type: 'object'
properties:
message:
type: 'string'
enum: ['The new password has been saved!']

View File

@ -1,97 +0,0 @@
import ms from 'ms'
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import { errorsMessages as errorsConfirmed } from '../../../../tools/middlewares/authenticateUser'
import User from '../../../../models/User'
import { errorsMessages } from '..'
describe('POST /users/resetPassword', () => {
it('succeeds with valid email and generate a tempToken', async () => {
const email = 'contact@test.com'
const name = 'John'
await authenticateUserTest({ email, name, shouldBeConfirmed: true })
const userBefore = await User.findOne({ where: { name } })
expect(userBefore).not.toBeNull()
expect(userBefore?.tempToken).toBe(null)
expect(userBefore?.tempExpirationToken).toBe(null)
await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(200)
const userAfter = await User.findOne({ where: { name } })
expect(userAfter?.tempToken).not.toBeNull()
expect(userAfter?.tempExpirationToken).not.toBeNull()
})
it('succeeds even if there is already a password-reset request in progress (but outdated)', async () => {
const email = 'contact@test.com'
const name = 'John'
await authenticateUserTest({ email, name, shouldBeConfirmed: true })
await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(200)
const user = await User.findOne({ where: { name } })
expect(user).not.toBeNull()
if (user != null) {
user.tempExpirationToken = Date.now() - ms('2 hour')
await user.save()
}
await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(200)
})
it("fails with email address that doesn't exist", async () => {
const response = await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email: 'contact@test.com' })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(errorsMessages.email.notExist)
})
it('fails with unconfirmed account', async () => {
const email = 'contact@test.com'
const name = 'John'
await authenticateUserTest({ email, name, shouldBeConfirmed: false })
const response = await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(errorsConfirmed.invalidAccount)
})
it('fails if there is already a password-reset request in progress', async () => {
const email = 'contact@test.com'
const name = 'John'
await authenticateUserTest({ email, name, shouldBeConfirmed: true })
await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(200)
const response = await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
errorsMessages.password.alreadyInProgress
)
})
})

View File

@ -1,95 +0,0 @@
import ms from 'ms'
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import User from '../../../../models/User'
import { errorsMessages } from '..'
describe('PUT /users/resetPassword', () => {
it('succeeds and change the password so we can signin again', async () => {
const email = 'contact@test.com'
const name = 'John'
const password = 'test'
await authenticateUserTest({
name,
email,
password,
shouldBeConfirmed: true
})
await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(200)
const user = await User.findOne({ where: { name } })
expect(user).not.toBeNull()
const newPassword = 'newpassword'
await request(application)
.put('/users/resetPassword')
.send({ password: newPassword, tempToken: user?.tempToken })
.expect(200)
await request(application)
.post('/users/signin')
.send({ email, password: newPassword })
.expect(200)
})
it('fails with an invalid "tempToken"', async () => {
const response = await request(application)
.put('/users/resetPassword')
.send({ password: 'newpassword', tempToken: 'sometemptoken' })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
errorsMessages.tempToken.invalid
)
})
it('fails if there is no password and tempToken provided', async () => {
const response = await request(application)
.put('/users/resetPassword')
.send()
.expect(400)
expect(response.body.errors.length).toEqual(2)
})
it('fails if the tempToken is outdated', async () => {
const email = 'contact@test.com'
const name = 'John'
const password = 'test'
await authenticateUserTest({
name,
email,
password,
shouldBeConfirmed: true
})
await request(application)
.post('/users/resetPassword?redirectURI=someurl.com')
.send({ email })
.expect(200)
const user = await User.findOne({ where: { name } })
expect(user).not.toBeNull()
if (user != null) {
user.tempExpirationToken = Date.now() - ms('2 hour')
await user.save()
}
const newPassword = 'newpassword'
const response = await request(application)
.put('/users/resetPassword')
.send({ password: newPassword, tempToken: user?.tempToken })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
errorsMessages.tempToken.invalid
)
})
})

View File

@ -1,22 +0,0 @@
import { Router } from 'express'
import { postResetPasswordRouter } from './post'
import { putResetPasswordRouter } from './put'
export const resetPasswordRouter = Router()
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid',
notExist: "Email address doesn't exist"
},
password: {
alreadyInProgress: 'A request to reset-password is already in progress'
},
tempToken: {
invalid: '"tempToken" is invalid'
}
}
resetPasswordRouter.use('/', putResetPasswordRouter)
resetPasswordRouter.use('/', postResetPasswordRouter)

View File

@ -1,73 +0,0 @@
import { Request, Response, Router } from 'express'
import { body, query } from 'express-validator'
import ms from 'ms'
import { v4 as uuidv4 } from 'uuid'
import { errorsMessages as errorsConfirmed } from '../../../tools/middlewares/authenticateUser'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import UserSetting from '../../../models/UserSetting'
import { sendEmail } from '../../../tools/email/sendEmail'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid',
notExist: "Email address doesn't exist"
},
password: {
alreadyInProgress: 'A request to reset-password is already in progress'
},
tempToken: {
invalid: '"tempToken" is invalid'
}
}
export const postResetPasswordRouter = Router()
postResetPasswordRouter.post(
'/users/resetPassword',
[
body('email')
.trim()
.isEmail()
.withMessage(errorsMessages.email.mustBeValid),
query('redirectURI').notEmpty().trim()
],
validateRequest,
async (req: Request, res: Response) => {
const { email } = req.body as { email: string }
const { redirectURI } = req.query as { redirectURI: string }
const user = await User.findOne({ where: { email } })
if (user == null) {
throw new BadRequestError(errorsMessages.email.notExist)
}
if (!user.isConfirmed) {
throw new BadRequestError(errorsConfirmed.invalidAccount)
}
const isValidTempToken =
user.tempExpirationToken != null && user.tempExpirationToken > Date.now()
if (user.tempToken != null && isValidTempToken) {
throw new BadRequestError(errorsMessages.password.alreadyInProgress)
}
const tempToken = uuidv4()
user.tempToken = tempToken
user.tempExpirationToken = Date.now() + ms('1 hour')
await user.save()
const userSettings = await UserSetting.findOne({
where: { userId: user.id }
})
await sendEmail({
type: 'reset-password',
email,
url: `${redirectURI}?tempToken=${tempToken}`,
language: userSettings?.language,
theme: userSettings?.theme
})
return res.status(200).json({
message: 'Password-reset request successful, please check your emails!'
})
}
)

View File

@ -1,53 +0,0 @@
import bcrypt from 'bcryptjs'
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid',
notExist: "Email address doesn't exist"
},
password: {
alreadyInProgress: 'A request to reset-password is already in progress'
},
tempToken: {
invalid: '"tempToken" is invalid'
}
}
export const putResetPasswordRouter = Router()
putResetPasswordRouter.put(
'/users/resetPassword',
[
body('password')
.trim()
.notEmpty(),
body('tempToken')
.trim()
.notEmpty()
],
validateRequest,
async (req: Request, res: Response) => {
const { password, tempToken } = req.body as {
password: string
tempToken: string
}
const user = await User.findOne({ where: { tempToken } })
const isValidTempToken =
user?.tempExpirationToken != null && user.tempExpirationToken > Date.now()
if (user == null || !isValidTempToken) {
throw new BadRequestError(errorsMessages.tempToken.invalid)
}
const hashedPassword = await bcrypt.hash(password, 12)
user.password = hashedPassword
user.tempToken = null
user.tempExpirationToken = null
await user.save()
return res.status(200).json({ message: 'The new password has been saved!' })
}
)

View File

@ -1,29 +0,0 @@
/users/signin:
post:
tags:
- 'users'
summary: 'Signin the user'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
email:
type: 'string'
format: 'email'
password:
type: 'string'
format: 'password'
example: 'password'
required:
- 'email'
- 'password'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- '200':
content:
application/json:
schema:
$ref: '#/definitions/RefreshTokenResponse'

View File

@ -1,65 +1,80 @@
import request from 'supertest'
import bcrypt from 'bcryptjs'
import application from '../../../../application'
import User from '../../../../models/User'
import { errorsMessages } from '../post'
import { application } from '../../../../application.js'
import { refreshTokenExample } from '../../../../models/RefreshToken.js'
import { userExample } from '../../../../models/User.js'
import { expiresIn } from '../../../../tools/utils/jwtToken.js'
import { prismaMock } from '../../../../__test__/setup.js'
const payload = {
email: userExample.email,
password: userExample.password
}
describe('POST /users/signin', () => {
it('succeeds with valid credentials', async () => {
const email = 'contact@test.com'
const name = 'John'
const password = 'test'
const response = await request(application)
.post('/users/signup')
.send({ name, email, password })
.expect(201)
const user = await User.findOne({ where: { id: response.body.user.id } })
if (user != null) {
await request(application)
.get(`/users/confirmEmail?tempToken=${user.tempToken as string}`)
.send()
.expect(200)
}
await request(application)
.post('/users/signin')
.send({ email, password })
.expect(200)
it('succeeds', async () => {
prismaMock.user.findUnique.mockResolvedValue({
...userExample,
password: await bcrypt.hash(userExample.password as string, 12)
})
prismaMock.refreshToken.create.mockResolvedValue(refreshTokenExample)
const response = await application.inject({
method: 'POST',
url: '/users/signin',
payload
})
const responseJson = response.json()
expect(response.statusCode).toEqual(200)
expect(responseJson.type).toEqual('Bearer')
expect(responseJson.expiresIn).toEqual(expiresIn)
})
it('fails with unconfirmed account and valid credentials', async () => {
const email = 'contact@test.com'
const name = 'John'
const password = 'test'
await request(application)
.post('/users/signup')
.send({ name, email, password })
.expect(201)
await request(application)
.post('/users/signin')
.send({ email, password })
.expect(400)
it('fails with invalid user', async () => {
prismaMock.user.findUnique.mockResolvedValue(null)
const response = await application.inject({
method: 'POST',
url: '/users/signin',
payload
})
expect(response.statusCode).toEqual(400)
})
it('fails with invalid credentials', async () => {
const email = 'contact@test.com'
const name = 'John'
const password = 'test'
await request(application)
.post('/users/signup')
.send({ name, email, password })
.expect(201)
it('fails with invalid email', async () => {
prismaMock.user.findUnique.mockResolvedValue(null)
const response = await application.inject({
method: 'POST',
url: '/users/signin',
payload: {
...payload,
email: 'incorrect-email'
}
})
expect(response.statusCode).toEqual(400)
})
const response = await request(application)
.post('/users/signin')
.send({ email, password: 'some random password' })
.expect(400)
it("fails if user hasn't got a password", async () => {
prismaMock.user.findUnique.mockResolvedValue({
...userExample,
password: null
})
const response = await application.inject({
method: 'POST',
url: '/users/signin',
payload: payload
})
expect(response.statusCode).toEqual(400)
})
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
errorsMessages.invalidCredentials
)
it('fails with incorrect password', async () => {
prismaMock.user.findUnique.mockResolvedValue(userExample)
const response = await application.inject({
method: 'POST',
url: '/users/signin',
payload: {
...payload,
password: userExample.password
}
})
expect(response.statusCode).toEqual(400)
})
})

View File

@ -1,72 +1,72 @@
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import bcrypt from 'bcryptjs'
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import { userSchema } from '../../../models/User.js'
import {
expiresIn,
generateAccessToken,
generateRefreshToken,
ResponseJWT
} from '../../../tools/configurations/jwtToken'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
jwtSchema,
expiresIn
} from '../../../tools/utils/jwtToken.js'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid'
},
password: {
required: 'Password is required'
},
invalidCredentials: 'Invalid credentials'
}
const bodyPostSigninSchema = Type.Object({
email: userSchema.email,
password: userSchema.password
})
export const signinRouter = Router()
type BodyPostSigninSchemaType = Static<typeof bodyPostSigninSchema>
signinRouter.post(
'/users/signin',
[
body('email')
.trim()
.isEmail()
.withMessage(errorsMessages.email.mustBeValid),
body('password')
.notEmpty()
.withMessage(errorsMessages.password.required)
],
validateRequest,
async (req: Request, res: Response) => {
const { email, password } = req.body as {
email: string
password: string
}
const user = await User.findOne({ where: { email, isConfirmed: true } })
if (user == null) {
throw new BadRequestError(errorsMessages.invalidCredentials)
}
if (user.password == null) {
throw new BadRequestError(errorsMessages.invalidCredentials)
}
const isCorrectPassword = await bcrypt.compare(password, user.password)
if (!isCorrectPassword) {
throw new BadRequestError(errorsMessages.invalidCredentials)
}
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
const refreshToken = await generateRefreshToken({
currentStrategy: 'local',
id: user.id
})
const responseJWT: ResponseJWT = {
accessToken,
refreshToken,
expiresIn,
type: 'Bearer'
}
return res.status(200).json(responseJWT)
const postSigninSchema: FastifySchema = {
description: 'Signin the user',
tags: ['users'] as string[],
body: bodyPostSigninSchema,
response: {
200: Type.Object(jwtSchema),
400: fastifyErrors[400],
500: fastifyErrors[500]
}
)
} as const
export const postSigninUser: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Body: BodyPostSigninSchemaType
}>({
method: 'POST',
url: '/users/signin',
schema: postSigninSchema,
handler: async (request, reply) => {
const { email, password } = request.body
const user = await prisma.user.findUnique({
where: { email }
})
if (user == null) {
throw fastify.httpErrors.badRequest('Invalid credentials.')
}
if (user.password == null) {
throw fastify.httpErrors.badRequest('Invalid credentials.')
}
const isCorrectPassword = await bcrypt.compare(password, user.password)
if (!isCorrectPassword) {
throw fastify.httpErrors.badRequest('Invalid credentials.')
}
const accessToken = generateAccessToken({
currentStrategy: 'local',
id: user.id
})
const refreshToken = await generateRefreshToken({
currentStrategy: 'local',
id: user.id
})
reply.statusCode = 200
return {
accessToken,
refreshToken,
expiresIn,
type: 'Bearer'
}
}
})
}

View File

@ -1,16 +0,0 @@
/users/signout:
delete:
security:
- bearerAuth: []
tags:
- 'users'
summary: 'Signout the user to every connected devices'
responses:
allOf:
- $ref: '#/definitions/ForbiddenError'
- $ref: '#/definitions/UnauthorizedError'
- '200':
content:
application/json:
schema:
type: 'object'

View File

@ -1,23 +0,0 @@
/users/signout:
post:
tags:
- 'users'
summary: 'Signout the user'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
refreshToken:
type: 'string'
required:
- 'refreshToken'
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- '200':
content:
application/json:
schema:
type: 'object'

View File

@ -1,29 +1,28 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import RefreshToken from '../../../../models/RefreshToken'
import { application } from '../../../../application.js'
import { prismaMock } from '../../../../__test__/setup.js'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUserTest.js'
describe('DELETE /users/signout', () => {
it('succeeds and signout to every devices', async () => {
const email = 'johdoe@gmail.com'
const name = 'johndoe'
const password = 'test'
const userToken = await authenticateUserTest({
name,
email,
password,
shouldBeConfirmed: true
it('succeeds', async () => {
prismaMock.refreshToken.deleteMany.mockResolvedValue({
count: 1
})
await authenticateUserTest({ name, email, password, alreadySignedUp: true })
let refreshToken = await RefreshToken.findAll()
expect(refreshToken.length).toEqual(2)
await request(application)
.delete('/users/signout')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send()
.expect(200)
refreshToken = await RefreshToken.findAll()
expect(refreshToken.length).toEqual(0)
const { accessToken } = await authenticateUserTest()
const response = await application.inject({
method: 'DELETE',
url: '/users/signout',
headers: {
authorization: `Bearer ${accessToken}`
}
})
expect(response.statusCode).toEqual(200)
})
it('fails with empty authorization header', async () => {
const response = await application.inject({
method: 'DELETE',
url: '/users/signout'
})
expect(response.statusCode).toEqual(401)
})
})

View File

@ -1,35 +1,25 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import RefreshToken from '../../../../models/RefreshToken'
import { application } from '../../../../application.js'
import { refreshTokenExample } from '../../../../models/RefreshToken.js'
import { prismaMock } from '../../../../__test__/setup.js'
describe('POST /users/signout', () => {
it('succeeds and signout', async () => {
const userToken = await authenticateUserTest()
let refreshToken = await RefreshToken.findAll()
expect(refreshToken.length).toEqual(1)
await request(application)
.post('/users/signout')
.send({ refreshToken: userToken.refreshToken })
.expect(200)
refreshToken = await RefreshToken.findAll()
expect(refreshToken.length).toEqual(0)
it('succeeds', async () => {
prismaMock.refreshToken.findFirst.mockResolvedValue(refreshTokenExample)
const response = await application.inject({
method: 'POST',
url: '/users/signout',
payload: { refreshToken: refreshTokenExample.token }
})
expect(response.statusCode).toEqual(200)
})
it('fails with invalid refreshToken', async () => {
await authenticateUserTest()
let refreshToken = await RefreshToken.findAll()
expect(refreshToken.length).toEqual(1)
await request(application)
.post('/users/signout')
.send({ refreshToken: 'some invalid token' })
.expect(401)
refreshToken = await RefreshToken.findAll()
expect(refreshToken.length).toEqual(1)
prismaMock.refreshToken.findFirst.mockResolvedValue(null)
const response = await application.inject({
method: 'POST',
url: '/users/signout',
payload: { refreshToken: 'somerandomtoken' }
})
expect(response.statusCode).toEqual(404)
})
})

View File

@ -1,19 +1,45 @@
import { Request, Response, Router } from 'express'
import { Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { deleteEveryRefreshTokens } from '../__utils__/deleteEveryRefreshTokens'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import authenticateUser from '../../../tools/plugins/authenticateUser.js'
export const signoutEveryDevicesRouter = Router()
signoutEveryDevicesRouter.delete(
'/users/signout',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
const deleteSignoutSchema: FastifySchema = {
description: 'Signout the user to every connected devices',
tags: ['users'] as string[],
security: [
{
bearerAuth: []
}
await deleteEveryRefreshTokens(req.user.current.id)
res.status(200).json({})
] as Array<{ [key: string]: [] }>,
response: {
200: Type.Object({}),
400: fastifyErrors[400],
401: fastifyErrors[401],
403: fastifyErrors[403],
500: fastifyErrors[500]
}
)
} as const
export const deleteSignoutUser: FastifyPluginAsync = async (fastify) => {
await fastify.register(authenticateUser)
fastify.route({
method: 'DELETE',
url: '/users/signout',
schema: deleteSignoutSchema,
handler: async (request, reply) => {
if (request.user == null) {
throw fastify.httpErrors.forbidden()
}
await prisma.refreshToken.deleteMany({
where: {
userId: request.user.current.id
}
})
reply.statusCode = 200
return {}
}
})
}

View File

@ -1,9 +0,0 @@
import { Router } from 'express'
import { signoutEveryDevicesRouter } from './delete'
import { postSignoutRouter } from './post'
export const signoutRouter = Router()
signoutRouter.use('/', signoutEveryDevicesRouter)
signoutRouter.use('/', postSignoutRouter)

View File

@ -1,29 +1,52 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { Static, Type } from '@sinclair/typebox'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import RefreshToken from '../../../models/RefreshToken'
import { UnauthorizedError } from '../../../tools/errors/UnauthorizedError'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import { refreshTokensSchema } from '../../../models/RefreshToken.js'
export const postSignoutRouter = Router()
const bodyPostSignoutSchema = Type.Object({
refreshToken: refreshTokensSchema.token
})
postSignoutRouter.post(
'/users/signout',
[
body('refreshToken')
.trim()
.notEmpty()
],
validateRequest,
async (req: Request, res: Response) => {
const { refreshToken } = req.body as { refreshToken: string }
const foundRefreshToken = await RefreshToken.findOne({
where: { token: refreshToken }
})
if (foundRefreshToken == null) {
throw new UnauthorizedError()
}
await foundRefreshToken.destroy()
res.status(200).json({})
type BodyPostSignoutSchemaType = Static<typeof bodyPostSignoutSchema>
const postSignoutSchema: FastifySchema = {
description: 'Signout the user',
tags: ['users'] as string[],
body: bodyPostSignoutSchema,
response: {
200: Type.Object({}),
400: fastifyErrors[400],
404: fastifyErrors[404],
500: fastifyErrors[500]
}
)
} as const
export const postSignoutUser: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Body: BodyPostSignoutSchemaType
}>({
method: 'POST',
url: '/users/signout',
schema: postSignoutSchema,
handler: async (request, reply) => {
const { refreshToken } = request.body
const token = await prisma.refreshToken.findFirst({
where: {
token: refreshToken
}
})
if (token == null) {
throw fastify.httpErrors.notFound()
}
await prisma.refreshToken.delete({
where: {
id: token.id
}
})
reply.statusCode = 200
return {}
}
})
}

View File

@ -1,48 +0,0 @@
/users/signup:
post:
tags:
- 'users'
summary: 'Signup the user'
description: 'Allows a new user to signup, if success he would need to confirm his email.'
requestBody:
content:
application/json:
schema:
type: 'object'
properties:
email:
type: 'string'
format: 'email'
name:
type: 'string'
minLength: 3
maxLength: 30
example: 'user'
password:
type: 'string'
format: 'password'
example: 'password'
language:
allOf:
- $ref: '#/definitions/Language'
theme:
allOf:
- $ref: '#/definitions/Theme'
required:
- 'email'
- 'name'
- 'password'
parameters:
- name: 'redirectURI'
description: 'The redirect URI to redirect the user when he successfuly confirm his email (could be a signin page), if not provided it will redirect the user to a simple page with a message to tell the user he can now signin.'
in: 'query'
required: false
responses:
allOf:
- $ref: '#/definitions/BadRequestError'
- '201':
description: 'User created and send an email to confirm the account'
content:
application/json:
schema:
$ref: '#/definitions/User'

View File

@ -1,124 +1,54 @@
import request from 'supertest'
import { application } from '../../../../application.js'
import { userExample } from '../../../../models/User.js'
import { userSettingsExample } from '../../../../models/UserSettings.js'
import { prismaMock } from '../../../../__test__/setup.js'
import { formatErrors } from '../../../../__test__/utils/formatErrors'
import application from '../../../../application'
import User from '../../../../models/User'
import { commonErrorsMessages } from '../../../../tools/configurations/constants'
import { errorsMessages } from '../post'
const payload = {
name: userExample.name,
email: userExample.email,
password: userExample.password,
theme: userSettingsExample.theme,
language: userSettingsExample.language
}
describe('POST /users/signup', () => {
it('succeeds and create a new user', async () => {
let users = await User.findAll()
expect(users.length).toEqual(0)
await request(application)
.post('/users/signup')
.send({
name: 'John',
email: 'contact@test.com',
password: 'test'
})
.expect(201)
users = await User.findAll()
expect(users.length).toEqual(1)
it('succeeds', async () => {
prismaMock.user.findFirst.mockResolvedValue(null)
prismaMock.user.create.mockResolvedValue(userExample)
prismaMock.userSetting.create.mockResolvedValue(userSettingsExample)
const response = await application.inject({
method: 'POST',
url: '/users/signup',
payload
})
const responseJson = response.json()
expect(response.statusCode).toEqual(201)
expect(responseJson.user.name).toEqual(userExample.name)
expect(responseJson.user.email).toEqual(userExample.email)
})
it('fails with invalid email', async () => {
let users = await User.findAll()
expect(users.length).toEqual(0)
const response = await request(application)
.post('/users/signup')
.send({
name: 'Divlo',
email: 'incorrect@email',
password: 'test'
})
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
errorsMessages.email.mustBeValid
)
users = await User.findAll()
expect(users.length).toEqual(0)
prismaMock.user.findFirst.mockResolvedValue(null)
prismaMock.user.create.mockResolvedValue(userExample)
prismaMock.userSetting.create.mockResolvedValue(userSettingsExample)
const response = await application.inject({
method: 'POST',
url: '/users/signup',
payload: {
...payload,
email: 'incorrect-email'
}
})
expect(response.statusCode).toEqual(400)
})
it('fails with invalid name', async () => {
let users = await User.findAll()
expect(users.length).toEqual(0)
const response = await request(application)
.post('/users/signup')
.send({
name: 'jo',
email: 'contact@email.com',
password: 'test'
})
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
)
users = await User.findAll()
expect(users.length).toEqual(0)
})
it('fails with invalid name and invalid email', async () => {
let users = await User.findAll()
expect(users.length).toEqual(0)
const response = await request(application)
.post('/users/signup')
.send({
name: 'jo',
email: 'contact@email',
password: 'test'
})
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(2)
expect(errors).toEqual(
expect.arrayContaining([
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 }),
errorsMessages.email.mustBeValid
])
)
users = await User.findAll()
expect(users.length).toEqual(0)
})
it('fails with name and email already used', async () => {
const name = 'John'
const email = 'contact@test.com'
await request(application)
.post('/users/signup')
.send({
name,
email,
password: 'test'
})
.expect(201)
const response = await request(application)
.post('/users/signup')
.send({
name,
email,
password: 'test'
})
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors.length).toEqual(2)
expect(errors).toEqual(
expect.arrayContaining(['Name already used', 'Email already used'])
)
it('fails with already taken `name` or `email`', async () => {
prismaMock.user.findFirst.mockResolvedValue(userExample)
const response = await application.inject({
method: 'POST',
url: '/users/signup',
payload
})
expect(response.statusCode).toEqual(400)
})
})

View File

@ -1,102 +1,92 @@
import { randomUUID } from 'node:crypto'
import { Static, Type } from '@sinclair/typebox'
import bcrypt from 'bcryptjs'
import { Request, Response, Router } from 'express'
import { body, query } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { FastifyPluginAsync, FastifySchema } from 'fastify'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import User from '../../../models/User'
import UserSetting, {
Language,
languages,
Theme,
themes
} from '../../../models/UserSetting'
import { commonErrorsMessages } from '../../../tools/configurations/constants'
import { sendEmail } from '../../../tools/email/sendEmail'
import { alreadyUsedValidation } from '../../../tools/validations/alreadyUsedValidation'
import { onlyPossibleValuesValidation } from '../../../tools/validations/onlyPossibleValuesValidation'
import prisma from '../../../tools/database/prisma.js'
import { fastifyErrors } from '../../../models/utils.js'
import {
bodyUserSchema,
BodyUserSchemaType,
userPublicSchema
} from '../../../models/User.js'
import { sendEmail } from '../../../tools/email/sendEmail.js'
import { HOST, PORT } from '../../../tools/configurations/index.js'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid'
const queryPostSignupSchema = Type.Object({
redirectURI: Type.Optional(Type.String({ format: 'uri-reference' }))
})
type QueryPostSignupSchemaType = Static<typeof queryPostSignupSchema>
const postSignupSchema: FastifySchema = {
description:
'Allows a new user to signup, if success he would need to confirm his email.',
tags: ['users'] as string[],
body: bodyUserSchema,
querystring: queryPostSignupSchema,
response: {
201: Type.Object({ user: Type.Object(userPublicSchema) }),
400: fastifyErrors[400],
500: fastifyErrors[500]
}
}
} as const
export const signupRouter = Router()
signupRouter.post(
'/users/signup',
[
body('email')
.trim()
.notEmpty()
.isEmail()
.withMessage(errorsMessages.email.mustBeValid)
.custom(async (email: string) => {
return await alreadyUsedValidation(User, 'email', email)
}),
body('name')
.trim()
.notEmpty()
.isLength({ max: 30, min: 3 })
.withMessage(
commonErrorsMessages.charactersLength('name', { max: 30, min: 3 })
)
.custom(async (name: string) => {
return await alreadyUsedValidation(User, 'name', name)
}),
body('password').trim().notEmpty().isString(),
body('theme')
.optional({ nullable: true })
.trim()
.isString()
.custom(async (theme: Theme) => {
return await onlyPossibleValuesValidation([...themes], 'theme', theme)
}),
body('language')
.optional({ nullable: true })
.trim()
.isString()
.custom(async (language: Language) => {
return await onlyPossibleValuesValidation(
languages,
'language',
language
export const postSignupUser: FastifyPluginAsync = async (fastify) => {
fastify.route<{
Body: BodyUserSchemaType
Querystring: QueryPostSignupSchemaType
}>({
method: 'POST',
url: '/users/signup',
schema: postSignupSchema,
handler: async (request, reply) => {
const { name, email, password, theme, language } = request.body
const { redirectURI } = request.query
const userValidation = await prisma.user.findFirst({
where: {
OR: [{ email }, { name }]
}
})
if (userValidation != null) {
throw fastify.httpErrors.badRequest(
'body.email or body.name already taken.'
)
}),
query('redirectURI').optional({ nullable: true }).trim()
],
validateRequest,
async (req: Request, res: Response) => {
const { name, email, password, theme, language } = req.body as {
name: string
email: string
password: string
theme?: Theme
language?: Language
}
const hashedPassword = await bcrypt.hash(password, 12)
const temporaryToken = randomUUID()
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
temporaryToken
}
})
const userSettings = await prisma.userSetting.create({
data: {
userId: user.id,
theme,
language
}
})
const redirectQuery =
redirectURI != null ? `&redirectURI=${redirectURI}` : ''
await sendEmail({
type: 'confirm-email',
email,
url: `${request.protocol}://${HOST}:${PORT}/users/confirm-email?temporaryToken=${temporaryToken}${redirectQuery}`,
language,
theme
})
reply.statusCode = 201
return {
user: {
...user,
settings: { ...userSettings }
}
}
}
const { redirectURI } = req.query as { redirectURI?: string }
const hashedPassword = await bcrypt.hash(password, 12)
const tempToken = uuidv4()
const user = await User.create({
email,
name,
password: hashedPassword,
tempToken
})
const userSettings = await UserSetting.create({
userId: user.id,
theme: theme ?? 'dark',
language: language ?? 'en'
})
const redirectQuery = redirectURI != null ? `&redirectURI=${redirectURI}` : ''
await sendEmail({
type: 'confirm-email',
email,
url: `${process.env.API_BASE_URL}/users/confirmEmail?tempToken=${tempToken}${redirectQuery}`,
language: userSettings.language,
theme: userSettings.theme
})
return res.status(201).json({ user })
}
)
})
}