chore: initial commit

This commit is contained in:
Divlo
2021-10-24 04:06:16 +02:00
commit 714cc643ba
260 changed files with 40783 additions and 0 deletions

View File

@ -0,0 +1,18 @@
/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

@ -0,0 +1,41 @@
import request from 'supertest'
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']))
})
})

View File

@ -0,0 +1,30 @@
import { Request, Response, Router } from 'express'
import User from '../../../models/User'
import UserSetting from '../../../models/UserSetting'
import { NotFoundError } from '../../../tools/errors/NotFoundError'
export const getUsersRouter = Router()
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 })
}
)

View File

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

View File

@ -0,0 +1,99 @@
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

@ -0,0 +1,89 @@
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

@ -0,0 +1,119 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,12 @@
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

@ -0,0 +1,38 @@
/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

@ -0,0 +1,76 @@
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

@ -0,0 +1,79 @@
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,22 @@
/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

@ -0,0 +1,43 @@
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

@ -0,0 +1,48 @@
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

@ -0,0 +1,21 @@
/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

@ -0,0 +1,45 @@
/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

@ -0,0 +1,46 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
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('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)
})
})

View File

@ -0,0 +1,228 @@
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'
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
})
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
})
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)
})
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
})
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 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'])
)
})
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
})
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])
)
})
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
})
const response = await request(application)
.put('/users/current')
.set('Authorization', `${userToken.type} ${userToken.accessToken}`)
.send({ email })
.expect(400)
const errors = formatErrors(response.body.errors)
expect(errors).toEqual(
expect.arrayContaining([errorsMessages.email.alreadyConnected])
)
})
})

View File

@ -0,0 +1,36 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import OAuth, { AuthenticationStrategy } from '../../../models/OAuth'
import UserSetting from '../../../models/UserSetting'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
export const getCurrentRouter = Router()
getCurrentRouter.get(
'/users/current',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
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
})
}
)

View File

@ -0,0 +1,21 @@
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,141 @@
import { Request, Response, Router } from 'express'
import fileUpload from 'express-fileupload'
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 {
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'
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'
}
}
export const putCurrentRouter = Router()
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)
}
const tempToken = uuidv4()
user.tempToken = tempToken
user.isConfirmed = false
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
})
}
const userSaved = await user.save()
return res
.status(200)
.json({ user: userSaved, strategy: req.user.currentStrategy })
}
)

View File

@ -0,0 +1,24 @@
/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

@ -0,0 +1,66 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../../__test__/utils/authenticateUser'
import application from '../../../../../application'
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
})
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)
})
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)
})
})

View File

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

View File

@ -0,0 +1,63 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
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'
export const putCurrentSettingsRouter = Router()
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()
}
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 })
}
)

View File

@ -0,0 +1,26 @@
import { Router } from 'express'
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'
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)

View File

@ -0,0 +1,24 @@
/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

@ -0,0 +1,55 @@
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

@ -0,0 +1,81 @@
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

@ -0,0 +1,77 @@
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

@ -0,0 +1,79 @@
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

@ -0,0 +1,68 @@
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

@ -0,0 +1,164 @@
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

@ -0,0 +1,161 @@
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

@ -0,0 +1,162 @@
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

@ -0,0 +1,15 @@
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,24 @@
/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

@ -0,0 +1,26 @@
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

@ -0,0 +1,55 @@
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,37 @@
/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

@ -0,0 +1,33 @@
/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

@ -0,0 +1,97 @@
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

@ -0,0 +1,95 @@
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

@ -0,0 +1,22 @@
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

@ -0,0 +1,73 @@
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,29 @@
/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

@ -0,0 +1,65 @@
import request from 'supertest'
import application from '../../../../application'
import User from '../../../../models/User'
import { errorsMessages } from '../post'
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('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 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)
const response = await request(application)
.post('/users/signin')
.send({ email, password: 'some random password' })
.expect(400)
expect(response.body.errors.length).toEqual(1)
expect(response.body.errors[0].message).toBe(
errorsMessages.invalidCredentials
)
})
})

View File

@ -0,0 +1,72 @@
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 {
expiresIn,
generateAccessToken,
generateRefreshToken,
ResponseJWT
} from '../../../tools/configurations/jwtToken'
import { BadRequestError } from '../../../tools/errors/BadRequestError'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid'
},
password: {
required: 'Password is required'
},
invalidCredentials: 'Invalid credentials'
}
export const signinRouter = Router()
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)
}
)

View File

@ -0,0 +1,16 @@
/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

@ -0,0 +1,23 @@
/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

@ -0,0 +1,29 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import RefreshToken from '../../../../models/RefreshToken'
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
})
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)
})
})

View File

@ -0,0 +1,35 @@
import request from 'supertest'
import { authenticateUserTest } from '../../../../__test__/utils/authenticateUser'
import application from '../../../../application'
import RefreshToken from '../../../../models/RefreshToken'
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('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)
})
})

View File

@ -0,0 +1,19 @@
import { Request, Response, Router } from 'express'
import { authenticateUser } from '../../../tools/middlewares/authenticateUser'
import { ForbiddenError } from '../../../tools/errors/ForbiddenError'
import { deleteEveryRefreshTokens } from '../__utils__/deleteEveryRefreshTokens'
export const signoutEveryDevicesRouter = Router()
signoutEveryDevicesRouter.delete(
'/users/signout',
authenticateUser,
async (req: Request, res: Response) => {
if (req.user == null) {
throw new ForbiddenError()
}
await deleteEveryRefreshTokens(req.user.current.id)
res.status(200).json({})
}
)

View File

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

View File

@ -0,0 +1,29 @@
import { Request, Response, Router } from 'express'
import { body } from 'express-validator'
import { validateRequest } from '../../../tools/middlewares/validateRequest'
import RefreshToken from '../../../models/RefreshToken'
import { UnauthorizedError } from '../../../tools/errors/UnauthorizedError'
export const postSignoutRouter = Router()
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({})
}
)

View File

@ -0,0 +1,48 @@
/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

@ -0,0 +1,124 @@
import request from 'supertest'
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'
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('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)
})
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'])
)
})
})

View File

@ -0,0 +1,102 @@
import bcrypt from 'bcryptjs'
import { Request, Response, Router } from 'express'
import { body, query } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
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'
export const errorsMessages = {
email: {
mustBeValid: 'Email must be valid'
}
}
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
)
}),
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 { 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 })
}
)