feat: add user and session

This commit is contained in:
Daniel Soares
2021-12-15 14:05:27 -03:00
parent 5df44ceddc
commit e071c0a19b
11 changed files with 577 additions and 9 deletions

View File

@@ -23,6 +23,9 @@
"@prisma/client": "^3.6.0",
"@stitches/react": "^1.2.6",
"axios": "^0.24.0",
"bcryptjs": "^2.4.3",
"dotenv": "^10.0.0",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.21",
"next": "12.0.7",
"next-connect": "^0.11.0",
@@ -35,6 +38,8 @@
"devDependencies": {
"@commitlint/cli": "15.0.0",
"@commitlint/config-conventional": "15.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/jsonwebtoken": "^8.5.6",
"@types/lodash": "^4.14.178",
"@types/node": "^16.11.12",
"@types/react": "17.0.37",

View File

@@ -11,3 +11,22 @@ model Data {
id Int @id @default(autoincrement())
data String
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
role Role
}
model Session {
id String @id @default(cuid())
userId Int
expiresAt DateTime
createdAt DateTime @default(now())
}
enum Role {
ADMIN
EDITOR
}

View File

@@ -1,7 +1,13 @@
import { PrismaClient } from '@prisma/client'
import { hashSync } from 'bcryptjs'
import dotenv from 'dotenv'
const prisma = new PrismaClient()
dotenv.config({
path: './.env'
})
async function main() {
const data = {
settings: {
@@ -62,9 +68,7 @@ async function main() {
}
const hasData = await prisma.data.findUnique({
where: {
id: 1
}
where: { id: 1 }
})
if (!hasData) {
@@ -77,6 +81,26 @@ async function main() {
}
})
}
const hasUser = await prisma.user.findFirst()
if (!hasUser) {
console.log(`Start seeding user...`)
const { USERNAME, PASSWORD } = process.env
if (!USERNAME || !PASSWORD) {
throw new Error('USERNAME and PASSWORD env variables are required')
}
await prisma.user.create({
data: {
username: USERNAME,
password: hashSync(PASSWORD, 10),
role: 'ADMIN'
}
})
}
}
main()

View File

@@ -0,0 +1,58 @@
import { Prisma } from '@prisma/client'
import { compare } from 'bcryptjs'
import type { NextApiRequest, NextApiResponse } from 'next'
import nc from 'next-connect'
import { prisma } from '../../../services/prisma'
import { createSession } from '../../../utils/createSession'
import { ExceptionError } from '../../../utils/error'
const handler = nc<NextApiRequest, NextApiResponse>({
onNoMatch: (_req, res) => {
res.status(404).json({ error: 'Not found' })
},
onError: (err, _req, res) => {
if (err instanceof ExceptionError) {
res.status(err.status).json({
status: err.status,
error: err.message
})
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
res.status(400).json({
status: 400,
error: err.message
})
} else {
res.status(500).json({
status: 500,
error: err.message
})
}
}
}).post(async (req, res) => {
const { username, password } = req.body
if (!username || !password) {
throw new ExceptionError('Username and password are required')
}
const user = await prisma.user.findUnique({
where: { username }
})
if (!user) {
throw new ExceptionError('Credentials are invalid', 401)
}
const isValidPassword = await compare(password, user.password)
if (!isValidPassword) {
throw new ExceptionError('Credentials are invalid', 401)
}
const { token, refreshToken } = await createSession(user)
res.status(200).json({ token, refreshToken })
})
export default handler

View File

@@ -0,0 +1,73 @@
import { Prisma } from '@prisma/client'
import type { NextApiRequest, NextApiResponse } from 'next'
import nc from 'next-connect'
import { prisma } from '../../../services/prisma'
import { createSession } from '../../../utils/createSession'
import { ExceptionError } from '../../../utils/error'
const handler = nc<NextApiRequest, NextApiResponse>({
onNoMatch: (_req, res) => {
res.status(404).json({ error: 'Not found' })
},
onError: (err, _req, res) => {
if (err instanceof ExceptionError) {
res.status(err.status).json({
status: err.status,
error: err.message
})
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
res.status(400).json({
status: 400,
error: err.message
})
} else {
res.status(500).json({
status: 500,
error: err.message
})
}
}
}).post(async (req, res) => {
const { refreshToken } = req.body
if (!refreshToken) {
throw new ExceptionError('Refresh Token required')
}
const session = await prisma.session.findUnique({
where: { id: refreshToken }
})
if (!session) {
throw new ExceptionError('Refresh Token are invalid', 401)
}
const isSessionExpired = new Date(session.expiresAt) < new Date()
if (isSessionExpired) {
await prisma.session.delete({
where: { id: refreshToken }
})
throw new ExceptionError('Refresh Token are expired', 401)
}
const user = await prisma.user.findUnique({
where: { id: session.userId }
})
if (!user) {
throw new ExceptionError('User not found', 401)
}
const tokens = await createSession(user)
await prisma.session.delete({
where: { id: refreshToken }
})
res.status(200).json({ token: tokens.token, refreshToken: tokens.refreshToken })
})
export default handler

View File

@@ -1,14 +1,31 @@
import { Prisma } from '@prisma/client'
import type { NextApiRequest, NextApiResponse } from 'next'
import nc from 'next-connect'
import { prisma } from '../../services/prisma'
import { ExceptionError } from '../../utils/error'
const handler = nc<NextApiRequest, NextApiResponse>({
onNoMatch: (_req, res) => {
res.status(404).json({ error: 'Not found' })
},
onError: (err, _req, res) => {
res.status(500).json({ error: err.message })
if (err instanceof ExceptionError) {
res.status(err.status).json({
status: err.status,
error: err.message
})
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
res.status(400).json({
status: 400,
error: err.message
})
} else {
res.status(500).json({
status: 500,
error: err.message
})
}
}
})
.get(async (_req, res) => {
@@ -17,7 +34,7 @@ const handler = nc<NextApiRequest, NextApiResponse>({
})
if (!response) {
throw new Error('No data found')
throw new ExceptionError('No data found')
}
res.status(200).json(response?.data)
@@ -27,7 +44,7 @@ const handler = nc<NextApiRequest, NextApiResponse>({
const { data } = req.body
if (!data) {
throw new Error('No data provided')
throw new ExceptionError('No data provided')
}
try {
@@ -38,8 +55,7 @@ const handler = nc<NextApiRequest, NextApiResponse>({
res.status(200).json(response)
} catch (err: any) {
console.error(err)
res.status(500).json({ error: err.message })
throw new ExceptionError(err)
}
})

144
src/pages/api/users/[id].ts Normal file
View File

@@ -0,0 +1,144 @@
import { Prisma } from '@prisma/client'
import { compare, hashSync } from 'bcryptjs'
import type { NextApiRequest, NextApiResponse } from 'next'
import nc from 'next-connect'
import { prisma } from '../../../services/prisma'
import { ExceptionError } from '../../../utils/error'
const handler = nc<NextApiRequest, NextApiResponse>({
onNoMatch: (_req, res) => {
res.status(404).json({ error: 'Not found' })
},
onError: (err, _req, res) => {
if (err instanceof ExceptionError) {
res.status(err.status).json({
status: err.status,
error: err.message
})
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
res.status(400).json({
status: 400,
error: err.message
})
} else {
res.status(500).json({
status: 500,
error: err.message
})
}
}
})
.get(async (req, res) => {
const { id } = req.query
if (!id) {
throw new ExceptionError('No id provided')
}
const response = await prisma.user.findUnique({
where: { id: +id },
select: {
id: true,
username: true,
role: true
}
})
if (!response) {
throw new ExceptionError('No data found', 404)
}
res.status(200).json(response)
})
.patch(async (req, res) => {
const { id } = req.query
const { username, password, newPassword, role } = req.body
const user = await prisma.user.findUnique({
where: { id: +id }
})
let updatedUser = {}
if (!user) {
throw new ExceptionError('No user found', 404)
}
if (!id) {
throw new ExceptionError('No id provided')
}
if (username) {
const usernameExists = await prisma.user.findUnique({
where: { username }
})
if (usernameExists && usernameExists.id !== +id) {
throw new ExceptionError('Username already exists')
}
updatedUser = { ...updatedUser, username }
}
if (password && newPassword) {
const isValidPassword = await compare(password, user.password)
if (!isValidPassword) {
throw new ExceptionError('Password does not match')
}
updatedUser = { password: hashSync(newPassword, 10) }
}
if (role) {
if (!['ADMIN', 'EDITOR'].includes(role)) {
throw new ExceptionError('Role must be either ADMIN or EDITOR')
}
if (user.role === 'ADMIN' && role === 'EDITOR') {
throw new ExceptionError('Cannot downgrade admin role', 403)
}
if (user.role === 'EDITOR') {
throw new ExceptionError('Cannot change role', 403)
}
updatedUser = { ...updatedUser, role }
}
try {
const response = await prisma.data.update({
where: { id: +id },
data: { ...updatedUser }
})
res.status(200).json(response)
} catch (err: any) {
throw new ExceptionError(err)
}
})
.delete(async (req, res) => {
const { id } = req.query
if (!id) {
throw new ExceptionError('No id provided')
}
if (id === '1') {
throw new ExceptionError('Cannot delete admin', 403)
}
try {
await prisma.data.delete({
where: { id: +id }
})
res.status(204).end()
} catch (err: any) {
throw new ExceptionError(err)
}
})
export default handler

View File

@@ -0,0 +1,84 @@
import { Prisma } from '@prisma/client'
import { hashSync } from 'bcryptjs'
import type { NextApiRequest, NextApiResponse } from 'next'
import nc from 'next-connect'
import { prisma } from '../../../services/prisma'
import { ExceptionError } from '../../../utils/error'
const handler = nc<NextApiRequest, NextApiResponse>({
onNoMatch: (_req, res) => {
res.status(404).json({ error: 'Not found' })
},
onError: (err, _req, res) => {
if (err instanceof ExceptionError) {
res.status(err.status).json({
status: err.status,
error: err.message
})
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
res.status(400).json({
status: 400,
error: err.message
})
} else {
res.status(500).json({
status: 500,
error: err.message
})
}
}
})
.get(async (_req, res) => {
const response = await prisma.user.findMany({
select: {
id: true,
username: true,
role: true
}
})
if (!response) {
throw new ExceptionError('No data found')
}
res.status(200).json(response)
})
.post(async (req, res) => {
const { username, password, role } = req.body
if (!username || !password) {
throw new ExceptionError('Username and password are required')
}
if (!['ADMIN', 'EDITOR'].includes(role)) {
throw new ExceptionError('Role must be either ADMIN or EDITOR')
}
// TODO implement role validation
const usernameExists = await prisma.user.findUnique({
where: { username }
})
if (usernameExists) {
throw new ExceptionError('Username already exists')
}
try {
const response = await prisma.user.create({
data: {
username,
password: hashSync(password, 10),
role: role ? role : 'EDITOR'
}
})
res.status(200).json(response)
} catch (err: any) {
throw new ExceptionError(err)
}
})
export default handler

View File

@@ -0,0 +1,34 @@
import { User } from '@prisma/client'
import jwt from 'jsonwebtoken'
import { prisma } from '../services/prisma'
import { ExceptionError } from './error'
const EXPIRES_IN = 30 // days
const EXPIRES_IN_MS = EXPIRES_IN * 24 * 60 * 60 * 1000
export async function createSession(user: User) {
const { JWT_SECRET } = process.env
if (!JWT_SECRET) {
throw new ExceptionError('JWT_SECRET is not defined')
}
try {
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + EXPIRES_IN_MS)
}
})
const payload = { role: user.role, sub: user.id }
const token = jwt.sign(payload, JWT_SECRET, {
expiresIn: '15m'
})
return { token, refreshToken: session.id }
} catch (err: any) {
throw new ExceptionError(err)
}
}

9
src/utils/error.ts Normal file
View File

@@ -0,0 +1,9 @@
export class ExceptionError extends Error {
status: number
constructor(message: string, status: number = 400) {
super(message)
this.name = 'ExceptionError'
this.status = status
}
}

104
yarn.lock
View File

@@ -450,11 +450,23 @@
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==
"@types/bcryptjs@^2.4.2":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@types/bcryptjs/-/bcryptjs-2.4.2.tgz#e3530eac9dd136bfdfb0e43df2c4c5ce1f77dfae"
integrity sha512-LiMQ6EOPob/4yUL66SZzu6Yh77cbzJFYll+ZfaPiPPFswtIlA/Fs1MzdKYA7JApHU49zQTbJGX3PDmCpIdDBRQ==
"@types/json5@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/jsonwebtoken@^8.5.6":
version "8.5.6"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.6.tgz#1913e5a61e70a192c5a444623da4901a7b1a9d42"
integrity sha512-+P3O/xC7nzVizIi5VbF34YtqSonFsdnbXBnWUCYRiKOi1f9gA4sEFvXkrGr/QVV23IbMYvcoerI7nnhDUiWXRQ==
dependencies:
"@types/node" "*"
"@types/lodash@^4.14.178":
version "4.14.178"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8"
@@ -800,6 +812,11 @@ base64-js@^1.0.2, base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
bcryptjs@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@@ -921,6 +938,11 @@ browserslist@4.16.6:
escalade "^3.1.1"
node-releases "^1.1.71"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
@@ -1466,6 +1488,18 @@ dot-prop@^5.1.0:
dependencies:
is-obj "^2.0.0"
dotenv@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
electron-to-chromium@^1.3.723:
version "1.4.16"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.16.tgz#38ddecc616385e6f101359d1b978c802664157d2"
@@ -2598,6 +2632,22 @@ jsonparse@^1.2.0:
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==
dependencies:
jws "^3.2.2"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^5.6.0"
"jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz#720b97bfe7d901b927d87c3773637ae8ea48781b"
@@ -2606,6 +2656,23 @@ jsonparse@^1.2.0:
array-includes "^3.1.3"
object.assign "^4.1.2"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
kind-of@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
@@ -2711,11 +2778,46 @@ lodash.get@^4:
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@@ -3736,7 +3838,7 @@ scheduler@^0.20.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
"semver@2 || 3 || 4 || 5":
"semver@2 || 3 || 4 || 5", semver@^5.6.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==