chore: initial commit
This commit is contained in:
18
src/services/users/[userId]/__docs__/get.yaml
Normal file
18
src/services/users/[userId]/__docs__/get.yaml
Normal 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'
|
41
src/services/users/[userId]/__test__/get.test.ts
Normal file
41
src/services/users/[userId]/__test__/get.test.ts
Normal 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']))
|
||||
})
|
||||
})
|
30
src/services/users/[userId]/get.ts
Normal file
30
src/services/users/[userId]/get.ts
Normal 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 })
|
||||
}
|
||||
)
|
7
src/services/users/[userId]/index.ts
Normal file
7
src/services/users/[userId]/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { getUsersRouter } from './get'
|
||||
|
||||
export const usersGetByIdRouter = Router()
|
||||
|
||||
usersGetByIdRouter.use('/', getUsersRouter)
|
99
src/services/users/__docs__/_definitions.yaml
Normal file
99
src/services/users/__docs__/_definitions.yaml
Normal 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'
|
89
src/services/users/__utils__/OAuthStrategy.ts
Normal file
89
src/services/users/__utils__/OAuthStrategy.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
119
src/services/users/__utils__/__test__/OAuthStrategy.test.ts
Normal file
119
src/services/users/__utils__/__test__/OAuthStrategy.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
20
src/services/users/__utils__/__test__/buildQuery.test.ts
Normal file
20
src/services/users/__utils__/__test__/buildQuery.test.ts
Normal 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')
|
||||
})
|
12
src/services/users/__utils__/buildQueryURL.ts
Normal file
12
src/services/users/__utils__/buildQueryURL.ts
Normal 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
|
||||
}
|
12
src/services/users/__utils__/deleteEveryRefreshTokens.ts
Normal file
12
src/services/users/__utils__/deleteEveryRefreshTokens.ts
Normal 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()
|
||||
}
|
||||
}
|
38
src/services/users/addLocalStrategy/__docs__/post.yaml
Normal file
38
src/services/users/addLocalStrategy/__docs__/post.yaml
Normal 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'
|
76
src/services/users/addLocalStrategy/__test__/post.test.ts
Normal file
76
src/services/users/addLocalStrategy/__test__/post.test.ts
Normal 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)
|
||||
})
|
||||
})
|
79
src/services/users/addLocalStrategy/post.ts
Normal file
79
src/services/users/addLocalStrategy/post.ts
Normal 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 })
|
||||
}
|
||||
)
|
22
src/services/users/confirmEmail/__docs__/get.yaml
Normal file
22
src/services/users/confirmEmail/__docs__/get.yaml
Normal 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'
|
43
src/services/users/confirmEmail/__test__/get.test.ts
Normal file
43
src/services/users/confirmEmail/__test__/get.test.ts
Normal 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)
|
||||
})
|
||||
})
|
48
src/services/users/confirmEmail/get.ts
Normal file
48
src/services/users/confirmEmail/get.ts
Normal 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)
|
||||
}
|
||||
)
|
21
src/services/users/current/__docs__/get.yaml
Normal file
21
src/services/users/current/__docs__/get.yaml
Normal 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'
|
45
src/services/users/current/__docs__/put.yaml
Normal file
45
src/services/users/current/__docs__/put.yaml
Normal 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'
|
46
src/services/users/current/__test__/get.test.ts
Normal file
46
src/services/users/current/__test__/get.test.ts
Normal 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)
|
||||
})
|
||||
})
|
228
src/services/users/current/__test__/put.test.ts
Normal file
228
src/services/users/current/__test__/put.test.ts
Normal 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])
|
||||
)
|
||||
})
|
||||
})
|
36
src/services/users/current/get.ts
Normal file
36
src/services/users/current/get.ts
Normal 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
|
||||
})
|
||||
}
|
||||
)
|
21
src/services/users/current/index.ts
Normal file
21
src/services/users/current/index.ts
Normal 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)
|
141
src/services/users/current/put.ts
Normal file
141
src/services/users/current/put.ts
Normal 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 })
|
||||
}
|
||||
)
|
24
src/services/users/current/settings/__docs__/put.yaml
Normal file
24
src/services/users/current/settings/__docs__/put.yaml
Normal 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'
|
66
src/services/users/current/settings/__test__/put.test.ts
Normal file
66
src/services/users/current/settings/__test__/put.test.ts
Normal 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)
|
||||
})
|
||||
})
|
7
src/services/users/current/settings/index.ts
Normal file
7
src/services/users/current/settings/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { putCurrentSettingsRouter } from './put'
|
||||
|
||||
export const currentSettingsRouter = Router()
|
||||
|
||||
currentSettingsRouter.use('/', putCurrentSettingsRouter)
|
63
src/services/users/current/settings/put.ts
Normal file
63
src/services/users/current/settings/put.ts
Normal 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 })
|
||||
}
|
||||
)
|
26
src/services/users/index.ts
Normal file
26
src/services/users/index.ts
Normal 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)
|
24
src/services/users/oauth2/__docs__/delete.yaml
Normal file
24
src/services/users/oauth2/__docs__/delete.yaml
Normal 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'
|
55
src/services/users/oauth2/__test__/delete.test.ts
Normal file
55
src/services/users/oauth2/__test__/delete.test.ts
Normal 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])
|
||||
)
|
||||
})
|
||||
})
|
81
src/services/users/oauth2/__test__/discord.test.ts
Normal file
81
src/services/users/oauth2/__test__/discord.test.ts
Normal 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()
|
||||
})
|
||||
})
|
77
src/services/users/oauth2/__test__/github.test.ts
Normal file
77
src/services/users/oauth2/__test__/github.test.ts
Normal 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()
|
||||
})
|
||||
})
|
79
src/services/users/oauth2/__test__/google.test.ts
Normal file
79
src/services/users/oauth2/__test__/google.test.ts
Normal 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()
|
||||
})
|
||||
})
|
68
src/services/users/oauth2/delete.ts
Normal file
68
src/services/users/oauth2/delete.ts
Normal 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.`
|
||||
})
|
||||
}
|
||||
)
|
164
src/services/users/oauth2/discord.ts
Normal file
164
src/services/users/oauth2/discord.ts
Normal 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))
|
||||
}
|
||||
)
|
161
src/services/users/oauth2/github.ts
Normal file
161
src/services/users/oauth2/github.ts
Normal 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))
|
||||
}
|
||||
)
|
162
src/services/users/oauth2/google.ts
Normal file
162
src/services/users/oauth2/google.ts
Normal 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))
|
||||
}
|
||||
)
|
15
src/services/users/oauth2/index.ts
Normal file
15
src/services/users/oauth2/index.ts
Normal 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 }
|
24
src/services/users/refreshToken/__docs__/post.yaml
Normal file
24
src/services/users/refreshToken/__docs__/post.yaml
Normal 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'
|
26
src/services/users/refreshToken/__test__/post.test.ts
Normal file
26
src/services/users/refreshToken/__test__/post.test.ts
Normal 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)
|
||||
})
|
||||
})
|
55
src/services/users/refreshToken/post.ts
Normal file
55
src/services/users/refreshToken/post.ts
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
37
src/services/users/resetPassword/__docs__/post.yaml
Normal file
37
src/services/users/resetPassword/__docs__/post.yaml
Normal 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!'
|
||||
]
|
33
src/services/users/resetPassword/__docs__/put.yaml
Normal file
33
src/services/users/resetPassword/__docs__/put.yaml
Normal 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!']
|
97
src/services/users/resetPassword/__test__/post.test.ts
Normal file
97
src/services/users/resetPassword/__test__/post.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
95
src/services/users/resetPassword/__test__/put.test.ts
Normal file
95
src/services/users/resetPassword/__test__/put.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
22
src/services/users/resetPassword/index.ts
Normal file
22
src/services/users/resetPassword/index.ts
Normal 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)
|
73
src/services/users/resetPassword/post.ts
Normal file
73
src/services/users/resetPassword/post.ts
Normal 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!'
|
||||
})
|
||||
}
|
||||
)
|
53
src/services/users/resetPassword/put.ts
Normal file
53
src/services/users/resetPassword/put.ts
Normal 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!' })
|
||||
}
|
||||
)
|
29
src/services/users/signin/__docs__/post.yaml
Normal file
29
src/services/users/signin/__docs__/post.yaml
Normal 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'
|
65
src/services/users/signin/__test__/post.test.ts
Normal file
65
src/services/users/signin/__test__/post.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
72
src/services/users/signin/post.ts
Normal file
72
src/services/users/signin/post.ts
Normal 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)
|
||||
}
|
||||
)
|
16
src/services/users/signout/__docs__/delete.yaml
Normal file
16
src/services/users/signout/__docs__/delete.yaml
Normal 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'
|
23
src/services/users/signout/__docs__/post.yaml
Normal file
23
src/services/users/signout/__docs__/post.yaml
Normal 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'
|
29
src/services/users/signout/__test__/delete.test.ts
Normal file
29
src/services/users/signout/__test__/delete.test.ts
Normal 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)
|
||||
})
|
||||
})
|
35
src/services/users/signout/__test__/post.test.ts
Normal file
35
src/services/users/signout/__test__/post.test.ts
Normal 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)
|
||||
})
|
||||
})
|
19
src/services/users/signout/delete.ts
Normal file
19
src/services/users/signout/delete.ts
Normal 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({})
|
||||
}
|
||||
)
|
9
src/services/users/signout/index.ts
Normal file
9
src/services/users/signout/index.ts
Normal 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)
|
29
src/services/users/signout/post.ts
Normal file
29
src/services/users/signout/post.ts
Normal 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({})
|
||||
}
|
||||
)
|
48
src/services/users/signup/__docs__/post.yaml
Normal file
48
src/services/users/signup/__docs__/post.yaml
Normal 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'
|
124
src/services/users/signup/__test__/post.test.ts
Normal file
124
src/services/users/signup/__test__/post.test.ts
Normal 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'])
|
||||
)
|
||||
})
|
||||
})
|
102
src/services/users/signup/post.ts
Normal file
102
src/services/users/signup/post.ts
Normal 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 })
|
||||
}
|
||||
)
|
Reference in New Issue
Block a user