Authorization vs authentication Authorization - checking if password is correct. Authentication - checking if the user is the same as authorized initially. Principle & data flow The client is authorized by comparing email and password against database. On successful authorization the server issues an access and a refresh tokens for future authentication to avoid asking for credentials on every http request. Client stores access token in the local storage or memory and attaches it inside request headers for private api requests. Token is attached by 'request' interceptor in axiosWithAuth . For protected api 'verifyToken' middleware verifies the token. If a token is ok, the request goes forward. If a token is bad (compromised or outdated) a response with status(401) is returned. Access token is short and expires in 15 min. 'Response' interceptor in axiosWithAuth checks for 401 status and if it is 401, it makes additional request to update access token by presenting a refresh token, which has 30d expiry time. Refresh token is stored in secured cookies on the login and also kept in database. If refresh token is valid and available in database, then updated access and refreshed refresh tokens are issued. axiosWithAuth remembers the request with all parameters when it got 401 and after getting successful refreshed tokens it repeats initial http request. If refresh token is invalid or old, then access token is not issued, client is considered as unauthorized and new login is required. If a user is deleted from the database, he is still authorized, for short time until access token is expired (15 min). We should consider the duration of access token depending on sensitivity of our data. Tokens are also checked and refreshed at the initial app load in useEffect() on Main component mount. That's how we determine if a client logged in or out. For tokens we use JWT tokens, which contain encrypted (not hashed) payload (usually object with user email, role, etc...), validation time and a hash based on a secret keys, which are kept on server. Server can validate the token only if it knows the secrete keys. Secrete keys are kept in environment variables. server
// server.ts
import 'dotenv/config'
import express, { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
import morgan from 'morgan'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import { loginRouter } from './api/loginRouter'
import { registerRouter } from './api/registerRouter'
import { connectToDb } from './db/connectToDb'
import { logoutRouter } from './api/logoutRouter'
import { activateRouter } from './api/activateRouter'
import { refreshRouter } from './api/refreshRouter'
import { errorHandler } from './middleware/errorHandler'
const app = express()
connectToDb()
app.use(morgan('dev')) // http logs in terminal
app.use(express.json()) // parses incoming requests with JSON because we use lots of json, let it be default
app.use(cookieParser())
app.use(cors())
app.get('/', (req: ReqType, res:ResType) => res.send('This is from express.js'))
app.get('/api', (req: ReqType, res:ResType) => res.json({ message: '/api' }))
app.use('/api/register', registerRouter)
app.use('/api/login', loginRouter)
app.use('/api/logout', logoutRouter)
app.use('/api/activate', activateRouter)
app.use('/api/refresh', refreshRouter)
app.use(errorHandler)
const port = process.env.PORT_BACK_END
const domain = process.env.DOMAIN
app.listen(port, () => console.log(`server started at ${domain}:${port}`))
registerRouter
// registerRouter.ts
import express, { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
// import { connectToDb } from '../db/connectToDb'
import { UserModel } from '../db/models/user.model'
import bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
import { sendMail } from '../services/mail/sendMail'
import { body, validationResult } from 'express-validator'
const domain = process.env.DOMAIN
const port = process.env.PORT_FRONT_END
export const registerRouter = express.Router()
registerRouter.post(
'/',
body('email').isEmail(),
body('password').isLength({ min: 3 }),
async (req: ReqType, res: ResType, next: NextType) => {
try {
// validation
const validationErrors = validationResult(req)
if (!validationErrors.isEmpty()) return res.json({ status: 'error', message: 'validation error', validationErrors })
// check if user already exists
// await connectToDb()
const email = req.body.email.toLowerCase()
const user = await UserModel.findOne({ email })
if (user) return res.json({ status: 'error', message: 'user with such email already exists' })
// save user to db
const password = await bcrypt.hash(req.body.password, 10)
const activationLink = `${domain}:${port}/api/activate/${uuidv4()}`
await UserModel.create({ email, password, activationLink })
// send email with activation link
const subject = 'Activation for quotation.app'
const html = `<div><h1>Follow the link to confirm the registration</h1><a href="${activationLink}">${activationLink}</a></div> `
// await sendMail({ to: email, subject, html })
// all went good, send the response
res.json({ status: 'ok', message: 'user is registered' })
} catch (error: any) {
next(error)
}
}
)
activateRouter
// activateRouter
import express, { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
import { UserModel } from '../db/models/user.model'
const domain = process.env.DOMAIN
const port = process.env.PORT_FRONT_END
export const activateRouter = express.Router()
activateRouter.get('/:link', async (req: ReqType, res: ResType, next: NextType) => {
try {
const activationLink = `${domain}:${port}/api/activate/${req.params.link}`
const user = await UserModel.findOne({ activationLink })
if (!user) return res.json({ status: 'error', message: 'no account with such activation link' })
user.isActivated = true
await user.save()
return res.redirect(`${domain}:${port}/login`)
} catch (error: any) {
next(error)
}
})
loginRouter
// loginRouter.ts
import express, { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
import { UserModel } from '../db/models/user.model'
import bcrypt from 'bcryptjs'
import { getAccessJwtToken, getRefreshJwtToken, refreshJwtTokenExpirationSeconds } from '../services/jwt/jwt'
export const loginRouter = express.Router()
loginRouter.post('/', async (req: ReqType, res: ResType, next: NextType) => {
try {
// get mail & password from body
let { email, password } = req.body
email = email.toLowerCase()
// check email & password
const user = await UserModel.findOne({ email })
const isPasswordValid = user && await bcrypt.compare(password, user.password)
if (!user || !isPasswordValid) return res.json({ status: 'error', message: 'invalid credentials' })
// check if account is activated
if (!user.isActivated) return res.json({ status: 'error', message: 'account is not activated' })
// generate jwt tokens
// const refreshJwtTokenExpirationDays = 30
// const accessJwtToken = jwt.sign({ email }, process.env.JWT_ACCESS_SECRET as string, { expiresIn: '8h' })
const accessJwtToken = getAccessJwtToken({ email })
// const refreshJwtToken = jwt.sign({ email }, process.env.JWT_REFRESH_SECRET as string, { expiresIn: `${refreshJwtTokenExpirationDays}d` })
const refreshJwtToken = getRefreshJwtToken({ email })
// put refresh token in cookie
res.cookie('refreshJwtToken', refreshJwtToken, { maxAge: refreshJwtTokenExpirationSeconds * 1000, httpOnly: true })
// put refresh token in db (also update login date)
const filter = { email }
const update = { loggedAt: new Date(), refreshJwtToken }
await UserModel.findOneAndUpdate(filter, update)
// return access token to the client
res.json({ status: 'ok', message: `user with email: ${email} logged in and tokens are refreshed`, accessJwtToken })
} catch (error: any) {
next(error)
}
})
refreshRouter
// refreshRouter.ts
import express, { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
import { UserModel } from '../db/models/user.model'
import { getAccessJwtToken, getRefreshJwtToken, refreshJwtTokenExpirationSeconds, verifyRefreshJwtToken } from '../services/jwt/jwt'
export const refreshRouter = express.Router()
refreshRouter.get('/', async (req: ReqType, res: ResType, next: NextType) => {
try {
// get refresh token from cookie
const { refreshJwtToken } = req.cookies
if (!refreshJwtToken) res.json({ status: 'error', message: 'no refresh token found in cookies during token refresh, probably not authorized' })
// check if token is ok
const { email } = verifyRefreshJwtToken(refreshJwtToken)
if (!email) res.json({ status: 'error', message: 'refresh token is not validated, probably not authorized' })
// find token in db
const user = await UserModel.findOne({ refreshJwtToken })
if (!user) return res.json({ status: 'error', message: 'no user found with such refresh token in db' })
// generate refresh token and save in db
const updatedRefreshJwtToken = getRefreshJwtToken({ email })
res.cookie('refreshJwtToken', updatedRefreshJwtToken, { maxAge: refreshJwtTokenExpirationSeconds * 1000, httpOnly: true })
await UserModel.findOneAndUpdate({ email }, { refreshJwtToken: updatedRefreshJwtToken })
// generate access token and send to client
const accessJwtToken = getAccessJwtToken({ email })
// send response
res.json({ status: 'ok', message: `refresh token for email: ${email} is refreshed`, accessJwtToken })
} catch (error) {
next(error)
}
})
logoutRouter
// logoutRouter.ts
import express, { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
import { UserModel } from '../db/models/user.model'
// eslint-disable-next-line camelcase
import jwt_decode from 'jwt-decode'
export const logoutRouter = express.Router()
logoutRouter.get('/', async (req: ReqType, res: ResType, next: NextType) => {
try {
// check refresh token
const { refreshJwtToken } = req.cookies
if (!refreshJwtToken) res.json({ status: 'error', message: 'no refresh token in cookies, probably already logged out' })
// get email from refresh token
const { email }: { email: string } = jwt_decode(refreshJwtToken)
if (!email) res.json({ status: 'error', message: 'can not retrieve email from refresh token' })
// delete refreshJwtToken from cookie
res.clearCookie('refreshJwtToken')
// delete token from db
const user = await UserModel.findOne({ refreshJwtToken })
if (!user) return res.json({ status: 'error', message: 'no user find with such refresh token' })
user.refreshJwtToken = undefined
await user.save()
// send response
res.json({ status: 'ok', message: `user with email: ${email} logged out` })
} catch (error) {
next(error)
}
})
verifyToken middleware
// verifyToken.ts
import { Request as ReqType, Response as ResType, NextFunction as NextType } from 'express'
import { verifyAccessJwtToken } from '../services/jwt/jwt'
export function verifyToken(req: any, res: ResType, next: NextType) {
try {
const accessJwtToken = req.headers['access-jwt-token'] as string
const { email } = verifyAccessJwtToken(accessJwtToken)
req.email = email // can add email in header, maybe useful for something
next()
} catch (error: any) {
return res.status(401).send('accessJwtToken is not verified, user is not authorized')
}
}
get & verify tokens
// jwt.ts
import jwt, { JwtPayload } from 'jsonwebtoken'
const accessJwtTokenExpirationSeconds = 15 * 60 // 15 min
export const refreshJwtTokenExpirationSeconds = 30 * 24 * 60 * 60 // 30 days
export const getAccessJwtToken = (payload: string | object) => jwt.sign(payload, process.env.JWT_ACCESS_SECRET as string, { expiresIn: accessJwtTokenExpirationSeconds })
export const getRefreshJwtToken = (payload: string | object) => jwt.sign(payload, process.env.JWT_REFRESH_SECRET as string, { expiresIn: refreshJwtTokenExpirationSeconds })
export const verifyAccessJwtToken = (accessJwtToken: string) => jwt.verify(accessJwtToken, process.env.JWT_ACCESS_SECRET as string) as JwtPayload
export const verifyRefreshJwtToken = (refreshJwtToken: string) => jwt.verify(refreshJwtToken, process.env.JWT_REFRESH_SECRET as string) as JwtPayload
Axios
// axios.ts
import axios from 'axios'
export const axiosWithAuth = axios.create({ withCredentials: true })
axiosWithAuth.interceptors.request.use((config) => {
const accessJwtToken = localStorage.getItem('accessJwtToken')
if (config.headers && accessJwtToken) {
config.headers['access-jwt-token'] = accessJwtToken
}
return config
})
axiosWithAuth.interceptors.response.use(
(config) => {
return config
},
async (error) => {
const originalRequest = error.config
if (error.response.status === 401 && error.config && !error.config._isRetry) {
try {
originalRequest._isRetry = true
const response = await axios.get('/api/refresh', { withCredentials: true })
const accessJwtToken = response.data.accessJwtToken
accessJwtToken && localStorage.setItem('accessJwtToken', accessJwtToken)
!accessJwtToken && localStorage.removeItem('accessJwtToken')
return axiosWithAuth.request(originalRequest)
} catch (error) {
console.log('not authorized')
console.log(error)
}
}
if (error.response.status === 401) {
// todo: logout in redux
// todo: suggest to login
}
throw error
}
)
Frontend functions
async function registerUser(e: EventType) {
e.preventDefault()
try {
const method = 'POST'
const headers = { 'Content-Type': 'application/json' }
const { email, password } = inputValue
const body = JSON.stringify({ email, password })
const options = { method, headers, body }
const res = await fetch('/api/register', options)
const data = await res.json()
data.status === 'error' && data.message === 'user with such email already exists' && notify({ msg: 'Already registered', type: 'info', theme: 'light' })
data.status === 'ok' && notify({ msg: 'Check your email and confirm registration.', theme: 'light' })
console.log(data)
} catch (err) {
console.log(err)
notify({ msg: 'Registration failed', type: 'error', theme: 'light' })
} finally {
// remove spinner from the button
}
}
async function loginUser(e: EventType) {
e.preventDefault()
const method = 'POST'
const headers = { 'Content-Type': 'application/json' }
const { email, password } = credentials
const body = JSON.stringify({ email, password })
const options = { method, headers, body }
const res = await fetch('/api/login', options)
const data = await res.json()
console.log(data)
if (data.status === 'error') {
alert(data.message)
return localStorage.removeItem('accessJwtToken')
}
localStorage.setItem('accessJwtToken', data.accessJwtToken)
alert('logged in')
navigate('/')
}
async function refreshTokens() {
// todo: move function into 'functions' folder in a file of folder with credentials business logic
// todo: separate helper files by a business logic
try {
if (!localStorage.getItem('accessJwtToken')) return console.log('user is not logged in')
const response = await axios.get('/api/refresh', { withCredentials: true })
if (response.data.status === 'error') {
console.log(response.data.message)
localStorage.removeItem('accessJwtToken')
}
if (!response.data.accessJwtToken) return console.log(666)
const accessJwtToken = response.data.accessJwtToken
const jwtTokenPayload: {email: string | undefined} = jwt_decode(accessJwtToken)
const { email } = jwtTokenPayload
if (!email) return console.log('token is not valid')
localStorage.setItem('accessJwtToken', response.data.accessJwtToken)
console.log(response)
console.log(`tokens for user with email: ${email} are refreshed`)
} catch (error) {
console.log(error)
}
}
async function getUsersFromDb() {
try {
const res = await axiosWithAuth('/api/users')
console.log(res)
} catch (error) {
console.log(error)
}
}