feat: V2 with TypeScript

This commit is contained in:
Ben Elferink
2024-12-14 16:14:14 +02:00
parent f6c0a52ab5
commit a41cab872f
63 changed files with 13390 additions and 35925 deletions

21
server/.gitignore vendored
View File

@@ -1,21 +0,0 @@
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.eslintcache
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -1,25 +0,0 @@
const Account = require('../../models/Account')
const { signToken } = require('../../middlewares/jsonwebtoken')
async function loginWithToken(request, response, next) {
try {
const { uid } = request.auth
// Get account from DB, existance not verified because we are already authorized at this point
const foundAccount = await Account.findOne({ _id: uid }).select('-password')
// Generate access token
const token = signToken({ uid: foundAccount._id, role: foundAccount.role })
response.status(200).json({
message: 'Account fetched',
data: foundAccount,
token,
})
} catch (error) {
console.error(error)
response.status(500).send()
}
}
module.exports = loginWithToken

View File

@@ -1,59 +0,0 @@
const joi = require('joi')
const bcrypt = require('bcrypt')
const Account = require('../../models/Account')
const {signToken} = require('../../middlewares/jsonwebtoken')
async function login(request, response, next) {
try {
// Validate request data
await joi
.object({
username: joi.string().required(),
password: joi.string().required(),
})
.validateAsync(request.body)
} catch (error) {
return response.status(400).json({
error: 'ValidationError',
message: error.message,
})
}
try {
const {username, password} = request.body
// Get account from DB, and verify existance
const foundAccount = await Account.findOne({username})
if (!foundAccount) {
return response.status(400).json({
message: 'Bad credentials',
})
}
// Decrypt and verify password
const passOk = await bcrypt.compare(password, foundAccount.password)
if (!passOk) {
return response.status(400).json({
message: 'Bad credentials',
})
}
// Remove password from response data
foundAccount.password = undefined
delete foundAccount.password
// Generate access token
const token = signToken({uid: foundAccount._id, role: foundAccount.role})
response.status(200).json({
message: 'Succesfully logged-in',
data: foundAccount,
token,
})
} catch (error) {
console.error(error)
response.status(500).send()
}
}
module.exports = login

View File

@@ -1,60 +0,0 @@
const joi = require('joi')
const bcrypt = require('bcrypt')
const Account = require('../../models/Account')
const {signToken} = require('../../middlewares/jsonwebtoken')
async function register(request, response, next) {
try {
// Validate request data
await joi
.object({
username: joi.string().required(),
password: joi.string().required(),
})
.validateAsync(request.body)
} catch (error) {
return response.status(400).json({
error: 'ValidationError',
message: error.message,
})
}
try {
const {username, password} = request.body
// Verify account username as unique
const existingAccount = await Account.findOne({username})
if (existingAccount) {
return response.status(400).json({
error: username,
message: 'An account already exists with that "username"',
})
}
// Encrypt password
const salt = await bcrypt.genSalt(10)
const hash = await bcrypt.hash(password, salt)
// Create account
const newAccount = new Account({username, password: hash})
await newAccount.save()
// Remove password from response data
newAccount.password = undefined
delete newAccount.password
// Generate access token
const token = signToken({uid: newAccount._id, role: newAccount.role})
response.status(201).json({
message: 'Succesfully registered',
data: newAccount,
token,
})
} catch (error) {
console.error(error)
return response.status(500).send()
}
}
module.exports = register

View File

@@ -1,19 +0,0 @@
require('dotenv').config() // Secures variables
const app = require('./utils/app') // Backend App (server)
const mongo = require('./utils/mongo') // MongoDB (database)
const {PORT} = require('./constants')
const authRoutes = require('./routes/auth')
async function bootstrap() {
await mongo.connect()
app.get('/', (req, res) => res.status(200).json({message: 'Hello World!'}))
app.get('/healthz', (req, res) => res.status(200).send())
app.use('/auth', authRoutes)
app.listen(PORT, () => {
console.log(`✅ Server is listening on port: ${PORT}`)
})
}
bootstrap()

View File

@@ -1,39 +0,0 @@
const jwt = require('jsonwebtoken')
const {JWT_SECRET} = require('../constants')
const signToken = (payload = {}, expiresIn = '12h') => {
const token = jwt.sign(payload, JWT_SECRET, {expiresIn})
return token
}
const authorizeBearerToken = (request, response, next) => {
try {
const token = request.headers.authorization?.split(' ')[1]
if (!token) {
return response.status(400).json({
message: 'Token not provided',
})
}
const auth = jwt.verify(token, JWT_SECRET)
if (!auth) {
return response.status(401).json({
message: 'Unauthorized - invalid token',
})
}
request.auth = auth
next()
} catch (error) {
console.error(error)
return response.status(401).json({
message: 'Unauthorized - invalid token',
})
}
}
module.exports = {
authorizeBearerToken,
signToken,
}

5627
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,21 +2,28 @@
"name": "server",
"version": "0.1.0",
"private": true,
"main": "index.js",
"license": "ISC",
"type": "commonjs",
"main": "src/index.ts",
"scripts": {
"start": "nodemon index.js || node index.js"
"dev": "npx tsx src/index.ts",
"build": "rm -rf dist && npx tsc",
"start": "node dist/index.js"
},
"dependencies": {
"bcrypt": "^5.0.1",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"joi": "^17.4.2",
"jsonwebtoken": "^8.5.1",
"mongoose": "^6.0.12"
"dotenv": "^16.4.7",
"express": "^4.21.2",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.9.0"
},
"devDependencies": {
"nodemon": "^2.0.14"
"@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}

View File

@@ -1,19 +0,0 @@
const express = require('express')
const { authorizeBearerToken } = require('../middlewares/jsonwebtoken')
const register = require('../controllers/auth/register')
const login = require('../controllers/auth/login')
const loginWithToken = require('../controllers/auth/login-with-token')
// initialize router
const router = express.Router()
// POST at route: http://localhost:8080/auth/register
router.post('/register', [], register)
// POST at path: http://localhost:8080/auth/login
router.post('/login', [], login)
// GET at path: http://localhost:8080/auth/account
router.get('/login', [authorizeBearerToken], loginWithToken)
module.exports = router

10
server/src/@types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
import 'express'
import jwt from 'jsonwebtoken'
declare global {
namespace Express {
interface Request {
auth?: jwt.JwtPayload // { uid: string; role: string }
}
}
}

View File

@@ -0,0 +1,5 @@
export interface Account {
username: string
password: string
role: 'user' | 'admin'
}

View File

@@ -1,16 +1,11 @@
const ORIGIN = '*'
const PORT = process.env.PORT || 8080
// for "atlas" edit MONGO_URI in -> .env file || for "community server" edit <MyDatabase>
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/MyDatabase'
// For "MongoDB Atlas": edit MONGO_URI in -> .env file
// For "MongoDB Community Server": edit <DB_NAME> in -> MONGO_URI below
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/<DB_NAME>'
const MONGO_OPTIONS = {}
const JWT_SECRET = process.env.JWT_SECRET || 'unsafe_secret'
module.exports = {
ORIGIN,
PORT,
MONGO_URI,
MONGO_OPTIONS,
JWT_SECRET,
}
export { ORIGIN, PORT, MONGO_URI, MONGO_OPTIONS, JWT_SECRET }

View File

@@ -0,0 +1,32 @@
import { type RequestHandler } from 'express'
import jwt from '../../utils/jwt'
import Account from '../../models/Account'
const loginWithToken: RequestHandler = async (req, res, next) => {
try {
const { uid } = req.auth || {}
// Get account from DB, password is not verified because we're already token-authorized at this point
const account = await Account.findOne({ _id: uid }).select('-password')
if (!account) {
return next({
statusCode: 400,
message: 'Bad credentials',
})
}
// Generate access token
const token = jwt.signToken({ uid: account._id, role: account.role })
res.status(200).json({
message: 'Succesfully got account',
data: account,
token,
})
} catch (error) {
next(error)
}
}
export default loginWithToken

View File

@@ -0,0 +1,59 @@
import { type RequestHandler } from 'express'
import joi from '../../utils/joi'
import jwt from '../../utils/jwt'
import crypt from '../../utils/crypt'
import Account from '../../models/Account'
const login: RequestHandler = async (req, res, next) => {
try {
const validationError = await joi.validate(
{
username: joi.instance.string().required(),
password: joi.instance.string().required(),
},
req.body
)
if (validationError) {
return next(validationError)
}
const { username, password } = req.body
// Get account from DB, and verify existance
const account = await Account.findOne({ username })
if (!account) {
return next({
statusCode: 400,
message: 'Bad credentials',
})
}
// Verify password hash
const passOk = crypt.validate(password, account.password)
if (!passOk) {
return next({
statusCode: 400,
message: 'Bad credentials',
})
}
// Generate access token
const token = jwt.signToken({ uid: account._id, role: account.role })
// Remove password from response data
const { password: _, ...accountData } = account.toObject()
res.status(200).json({
message: 'Succesfully logged-in',
data: accountData,
token,
})
} catch (error) {
next(error)
}
}
export default login

View File

@@ -0,0 +1,56 @@
import { type RequestHandler } from 'express'
import joi from '../../utils/joi'
import jwt from '../../utils/jwt'
import crypt from '../../utils/crypt'
import Account from '../../models/Account'
const register: RequestHandler = async (req, res, next) => {
try {
const validationError = await joi.validate(
{
username: joi.instance.string().required(),
password: joi.instance.string().required(),
},
req.body
)
if (validationError) {
return next(validationError)
}
const { username, password } = req.body
// Verify account username as unique
const found = await Account.findOne({ username })
if (found) {
return next({
statusCode: 400,
message: 'An account already exists with that "username"',
})
}
// Encrypt password
const hash = crypt.hash(password)
// Create account
const account = new Account({ username, password: hash })
await account.save()
// Generate access token
const token = jwt.signToken({ uid: account._id, role: account.role })
// Exclude password from response
const { password: _, ...data } = account.toObject()
res.status(201).json({
message: 'Succesfully registered',
data,
token,
})
} catch (error) {
next(error)
}
}
export default register

28
server/src/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import dotenv from 'dotenv'
dotenv.config()
import app from './utils/app' // (server)
import mongo from './utils/mongo' // (database)
import { PORT } from './constants/index'
import authRoutes from './routes/auth'
const bootstrap = async () => {
await mongo.connect()
app.get('/', (req, res) => {
res.status(200).send('Hello, world!')
})
app.get('/healthz', (req, res) => {
res.status(204).end()
})
app.use('/auth', authRoutes)
// add rest of routes here...
app.listen(PORT, () => {
console.log(`✅ Server is listening on port: ${PORT}`)
})
}
bootstrap()

View File

@@ -0,0 +1,35 @@
import { type RequestHandler } from 'express'
import jwt from '../utils/jwt'
const checkBearerToken: RequestHandler = (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1]
if (!token) {
return next({
statusCode: 400,
message: 'Token not provided',
})
}
const auth = jwt.verifyToken(token)
if (!auth) {
return next({
statusCode: 401,
message: 'Invalid token',
})
}
req.auth = typeof auth === 'string' ? JSON.parse(auth) : auth
next()
} catch (error) {
next({
statusCode: 401,
message: 'Invalid token',
})
}
}
export default checkBearerToken

View File

@@ -0,0 +1,12 @@
import { type NextFunction, type Request, type Response } from 'express'
const errorHandler = (error: any, req: Request, res: Response, next: NextFunction) => {
const { statusCode = 500, message = 'Internal server error', ...rest } = error
res.status(statusCode).json({
message,
...rest,
})
}
export default errorHandler

View File

@@ -1,9 +1,12 @@
const mongoose = require('mongoose')
import { type Document, model, Schema } from 'mongoose'
import { type Account } from '../@types'
const instance = new mongoose.Schema(
interface I extends Document, Account {}
const instance = new Schema<I>(
{
/*
document ID is set by default via MongoDB - next line is deprecated
document ID is set by default via MongoDB - the next line is deprecated!
_id: mongoose.Schema.Types.ObjectId,
*/
@@ -26,11 +29,11 @@ const instance = new mongoose.Schema(
},
{
timestamps: true,
},
}
)
// NOTE! use a singular model name, mongoose automatically creates a collection like so:
// model: 'Account' === collection: 'accounts'
const modelName = 'Account'
module.exports = mongoose.model(modelName, instance)
export default model<I>(modelName, instance)

20
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import express from 'express'
import checkBearerToken from '../middlewares/check-bearer-token'
import errorHandler from '../middlewares/error-handler'
import register from '../controllers/auth/register'
import login from '../controllers/auth/login'
import loginWithToken from '../controllers/auth/login-with-token'
// initialize router
const router = express.Router()
// POST at route: http://localhost:8080/auth/register
router.post('/register', [], register, errorHandler)
// POST at path: http://localhost:8080/auth/login
router.post('/login', [], login, errorHandler)
// GET at path: http://localhost:8080/auth/account
router.get('/login', [checkBearerToken], loginWithToken, errorHandler)
export default router

13
server/src/utils/app.ts Normal file
View File

@@ -0,0 +1,13 @@
import express from 'express'
import cors from 'cors'
import { ORIGIN } from '../constants/index'
// initialize app
const app = express()
// middlewares
app.use(cors({ origin: ORIGIN }))
app.use(express.json()) // body parser
app.use(express.urlencoded({ extended: false })) // url parser
export default app

22
server/src/utils/crypt.ts Normal file
View File

@@ -0,0 +1,22 @@
import bcrypt from 'bcrypt'
class Crypt {
instance: typeof bcrypt = bcrypt
constructor() {}
async hash(value: string) {
const salt = await this.instance.genSalt(10)
const hash = await this.instance.hash(value, salt)
return hash
}
async validate(value: string, hash: string) {
const isOk = await bcrypt.compare(value, hash)
return isOk
}
}
export default new Crypt()

22
server/src/utils/joi.ts Normal file
View File

@@ -0,0 +1,22 @@
import joi from 'joi'
class Joi {
instance: typeof joi = joi
constructor() {}
async validate(schema: Record<string, any>, body: Record<string, any>) {
try {
await this.instance.object(schema).validateAsync(body)
} catch (error: any) {
console.log('❌ Joi validation error:', error.message)
return {
statusCode: 400,
message: error.message,
}
}
}
}
export default new Joi()

25
server/src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,25 @@
import jsonwebtoken from 'jsonwebtoken'
import { JWT_SECRET } from '../constants/index'
class JWT {
instance: typeof jsonwebtoken = jsonwebtoken
secret: string
constructor() {
this.secret = JWT_SECRET
}
signToken(payload: Record<string, any>, expiresIn: jsonwebtoken.SignOptions['expiresIn'] = '12h') {
const token = this.instance.sign(payload, JWT_SECRET, { expiresIn })
return token
}
verifyToken(token: string) {
const auth = this.instance.verify(token, JWT_SECRET)
return auth
}
}
export default new JWT()

View File

@@ -1,20 +1,25 @@
const mongoose = require('mongoose')
const {MONGO_URI} = require('../constants')
const {MONGO_OPTIONS} = require('../constants')
import mongoose from 'mongoose'
import { MONGO_URI, MONGO_OPTIONS } from '../constants/index'
class Mongo {
instance: typeof mongoose = mongoose
mongoUri: string
mongoOptions: mongoose.ConnectOptions
isConnected: boolean
class MongoDB {
constructor() {
this.mongoose = mongoose
this.mongoUri = MONGO_URI
this.mongoOptions = MONGO_OPTIONS
this.isConnected = false
this.MONGO_URI = MONGO_URI
this.MONGO_OPTIONS = MONGO_OPTIONS
}
async connect() {
if (this.isConnected) return
try {
const db = await this.mongoose.connect(this.MONGO_URI, this.MONGO_OPTIONS)
console.log('⏳ Connecting to MongoDB')
const db = await this.instance.connect(this.mongoUri, this.mongoOptions)
const connection = db.connection
this.isConnected = connection.readyState === 1
@@ -23,10 +28,10 @@ class MongoDB {
connection.on('connected', () => console.log('✅ MongoDB connected')) // re-connected
connection.on('disconnected', () => console.log('❌ MongoDB disconnected')) // disconnected
connection.on('error', (error) => console.log('❌ MongoDB connection error', error)) // listen for errors during the session
} catch (error) {
} catch (error: any) {
console.log('❌ MongoDB connection error:', error.message)
}
}
}
module.exports = new MongoDB()
export default new Mongo()

15
server/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,20 +0,0 @@
const express = require('express') // Backend App (server)
const cors = require('cors') // HTTP headers (enable requests)
const {ORIGIN} = require('../constants')
// initialize app
const app = express()
// middlewares
app.use(cors({origin: ORIGIN}))
app.use(express.json({extended: true})) // body parser
app.use(express.urlencoded({extended: false})) // url parser
// error handling
app.use((err, req, res, next) => {
console.error(err)
res.status(500).send()
next()
})
module.exports = app