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

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

@@ -0,0 +1,11 @@
const ORIGIN = '*'
const PORT = process.env.PORT || 8080
// 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'
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

@@ -0,0 +1,39 @@
import { type Document, model, Schema } from 'mongoose'
import { type Account } from '../@types'
interface I extends Document, Account {}
const instance = new Schema<I>(
{
/*
document ID is set by default via MongoDB - the next line is deprecated!
_id: mongoose.Schema.Types.ObjectId,
*/
username: {
type: String,
required: true,
lowercase: true,
unique: true,
},
password: {
type: String,
required: true,
},
role: {
type: String,
required: true,
enum: ['user', 'admin'],
default: 'user',
},
},
{
timestamps: true,
}
)
// NOTE! use a singular model name, mongoose automatically creates a collection like so:
// model: 'Account' === collection: 'accounts'
const modelName = 'Account'
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()

37
server/src/utils/mongo.ts Normal file
View File

@@ -0,0 +1,37 @@
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
constructor() {
this.mongoUri = MONGO_URI
this.mongoOptions = MONGO_OPTIONS
this.isConnected = false
}
async connect() {
if (this.isConnected) return
try {
console.log('⏳ Connecting to MongoDB')
const db = await this.instance.connect(this.mongoUri, this.mongoOptions)
const connection = db.connection
this.isConnected = connection.readyState === 1
if (this.isConnected) console.log('✅ MongoDB connected')
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: any) {
console.log('❌ MongoDB connection error:', error.message)
}
}
}
export default new Mongo()