feat: V2 with TypeScript
This commit is contained in:
10
server/src/@types/express.d.ts
vendored
Normal file
10
server/src/@types/express.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
auth?: jwt.JwtPayload // { uid: string; role: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
5
server/src/@types/index.ts
Normal file
5
server/src/@types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Account {
|
||||
username: string
|
||||
password: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
11
server/src/constants/index.ts
Normal file
11
server/src/constants/index.ts
Normal 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 }
|
||||
32
server/src/controllers/auth/login-with-token.ts
Normal file
32
server/src/controllers/auth/login-with-token.ts
Normal 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
|
||||
59
server/src/controllers/auth/login.ts
Normal file
59
server/src/controllers/auth/login.ts
Normal 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
|
||||
56
server/src/controllers/auth/register.ts
Normal file
56
server/src/controllers/auth/register.ts
Normal 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
28
server/src/index.ts
Normal 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()
|
||||
35
server/src/middlewares/check-bearer-token.ts
Normal file
35
server/src/middlewares/check-bearer-token.ts
Normal 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
|
||||
12
server/src/middlewares/error-handler.ts
Normal file
12
server/src/middlewares/error-handler.ts
Normal 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
|
||||
39
server/src/models/Account.ts
Normal file
39
server/src/models/Account.ts
Normal 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
20
server/src/routes/auth.ts
Normal 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
13
server/src/utils/app.ts
Normal 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
22
server/src/utils/crypt.ts
Normal 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
22
server/src/utils/joi.ts
Normal 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
25
server/src/utils/jwt.ts
Normal 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
37
server/src/utils/mongo.ts
Normal 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()
|
||||
Reference in New Issue
Block a user