Login expiration improved: It will be refreshed if possible, and if not, user is logged out and redirected to login page.

Added an environment variable for the grace period.
This commit is contained in:
ShootingStar91
2024-07-17 22:15:38 +03:00
parent e71798f55e
commit 1ae8578108
9 changed files with 131 additions and 25 deletions
+4
View File
@@ -9,6 +9,7 @@ import regionRouter from './routes/region'
import personRouter from './routes/person'
import projectRouter from './routes/project'
import speciesRouter from './routes/species'
import refreshTokenRouter from './routes/refresh'
import timeBoundRouter from './routes/timeBound'
import timeUnitRouter from './routes/timeUnit'
import userRouter from './routes/user'
@@ -34,6 +35,9 @@ app.use(responseLogger)
if (!ENABLE_WRITE && RUNNING_ENV !== 'dev') {
app.use(blockWriteRequests)
}
app.use(refreshTokenRouter)
app.use(tokenExtractor)
app.use(userExtractor)
+3 -3
View File
@@ -21,9 +21,9 @@ export const tokenExtractor: Middleware = (req, _res, next) => {
next()
}
const verify = async (token: string, secret: Secret) => {
export const verify = async (token: string, secret: Secret, ignoreExpiration?: boolean) => {
return new Promise((resolve, reject) => {
jwt.verify(token, secret, (err, decoded) => {
jwt.verify(token, secret, { ignoreExpiration }, (err, decoded) => {
if (err) reject(err)
else resolve(decoded)
})
@@ -78,7 +78,7 @@ export const userExtractor: Middleware = async (req, res, next) => {
return next()
} catch (e: unknown) {
if (e instanceof TokenExpiredError) {
return res.status(400).json({ message: 'Token expired' })
return res.status(401).json({ message: 'Token expired' })
}
next(e)
}
+6
View File
@@ -1,5 +1,6 @@
import express from 'express'
import { logger } from '../utils/logger'
import { JsonWebTokenError } from 'jsonwebtoken'
export const errorHandler = (
error: Error,
@@ -11,6 +12,11 @@ export const errorHandler = (
if ('status' in error) {
return res.status(error.status as number).json({ message: error.message })
}
if (error instanceof JsonWebTokenError) {
return res.status(400).json({ message: 'Authentication token was invalid' })
}
logger.error('Internal server error occurred. Error and stacktrace:')
logger.error(error.message)
if (error.stack) logger.error(error.stack)
+40
View File
@@ -0,0 +1,40 @@
import { Request, Router } from 'express'
import { verify } from '../middlewares/authenticator'
import { Secret, sign } from 'jsonwebtoken'
import { AccessError } from '../middlewares/authorizer'
import { SECRET, GRACE_PERIOD_SECONDS, LOGIN_VALID_SECONDS } from '../utils/config'
import { nowDb } from '../utils/db'
const router = Router()
router.post('/refreshToken', async (req: Request<object, { token: string }, { token: string }>, res) => {
if (!req.body.token) throw new AccessError()
const oldToken = req.body.token
const {
username,
exp,
id: userId,
} = (await verify(oldToken, SECRET as Secret, true)) as { username: string; exp: number; id: number }
const now = new Date().getTime()
console.log({ now, exp, grace: GRACE_PERIOD_SECONDS })
if (now > exp * 1000 + GRACE_PERIOD_SECONDS * 1000) throw new AccessError('Token expired. You must login again.')
const foundUser = await nowDb.com_users.findFirst({
where: {
user_name: username,
user_id: userId,
},
select: { user_name: true, user_id: true },
})
if (!foundUser) throw new AccessError()
const token = sign({ username: foundUser.user_name, id: foundUser.user_id }, SECRET, {
expiresIn: LOGIN_VALID_SECONDS,
})
res.status(200).json({ token })
})
export default router
+10 -7
View File
@@ -1,11 +1,12 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { Router } from 'express'
import * as jwt from 'jsonwebtoken'
import { SECRET, LOGIN_VALID_MS, USER_CREATION_SECRET } from '../utils/config'
import { sign } from 'jsonwebtoken'
import { SECRET, LOGIN_VALID_SECONDS, USER_CREATION_SECRET } from '../utils/config'
import * as bcrypt from 'bcrypt'
import { nowDb } from '../utils/db'
import { getRole } from '../middlewares/authenticator'
import { AccessError } from '../middlewares/authorizer'
import { AccessError, requireOneOf } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/types'
const router = Router()
@@ -22,8 +23,8 @@ router.post('/login', async (req, res) => {
if (!passwordMatches) return res.status(403).send()
const token = jwt.sign({ username: foundUser.user_name, id: foundUser.user_id }, SECRET, {
expiresIn: LOGIN_VALID_MS,
const token = sign({ username: foundUser.user_name, id: foundUser.user_id }, SECRET, {
expiresIn: LOGIN_VALID_SECONDS,
})
const personResult = await nowDb.com_people.findFirst({
@@ -54,11 +55,13 @@ const createPasswordHash = async (password: string) => {
return await bcrypt.hash(password, saltRounds)
}
router.post('/create', async (req, res) => {
// TODO type guards
router.post('/create', requireOneOf([Role.Admin]), async (req, res) => {
const { username, password, secret } = req.body
if (!secret || secret !== USER_CREATION_SECRET) throw Error('Wrong user creation secret')
const passwordHash = await createPasswordHash(password as string)
await nowDb.com_users.create({
data: {
user_name: username as string,
+6 -2
View File
@@ -2,7 +2,10 @@ export const MARIADB_HOST = process.env.MARIADB_HOST as string
export const MARIADB_PASSWORD = process.env.MARIADB_PASSWORD as string
export const SECRET = process.env.SECRET as string
export const PORT = process.env.NOWDB_BACKEND_PORT as unknown as number
export const LOGIN_VALID_MS = parseInt(process.env.LOGIN_VALID_MINUTES as string) * 60 * 1000
export const LOGIN_VALID_SECONDS = parseInt(process.env.LOGIN_VALID_MINUTES as string) * 60
export const GRACE_PERIOD_SECONDS = parseInt(process.env.GRACE_PERIOD_MINUTES || '60') * 60
export const MARIADB_PORT = parseInt(process.env.MARIADB_PORT as string)
export const USER_CREATION_SECRET = process.env.USER_CREATION_SECRET as string
@@ -28,7 +31,8 @@ const requiredEnvs = {
MARIADB_PASSWORD,
SECRET,
PORT,
LOGIN_VALID_MS,
LOGIN_VALID_SECONDS,
GRACE_PERIOD_SECONDS,
MARIADB_PORT,
USER_CREATION_SECRET,
BACKEND_MODE: RUNNING_ENV,
+11 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState, ChangeEvent, FormEvent } from 'react'
import { useTryLoginMutation, setUser } from '../redux/userReducer'
import { useDispatch } from 'react-redux'
import { Box, Button, CircularProgress, Container, Stack, TextField, Typography } from '@mui/material'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { api } from '../redux/api'
export const Login = () => {
@@ -13,6 +13,9 @@ export const Login = () => {
const [loginMutation, { data, isLoading, isError }] = useTryLoginMutation()
const dispatch = useDispatch()
const navigate = useNavigate()
const location = useLocation()
const search = new URLSearchParams(location.search)
const loginHadExpired = search.get('expired')
const login = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
@@ -42,6 +45,13 @@ export const Login = () => {
return (
<Container style={{ alignContent: 'center' }} maxWidth="sm">
<Box>
{loginHadExpired && (
<p style={{ color: 'darkorange', fontWeight: 'bold', fontSize: 28 }}>
Your login expired. Please login again.
</p>
)}
</Box>
<form onSubmit={event => void login(event)}>
<Stack rowGap="1em">
<TextField
+47 -11
View File
@@ -1,6 +1,51 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import {
BaseQueryFn,
createApi,
FetchArgs,
fetchBaseQuery,
FetchBaseQueryError,
FetchBaseQueryMeta,
} from '@reduxjs/toolkit/query/react'
import { BACKEND_URL } from '../util/config'
import { RootState } from './store'
import { clearUser, setToken } from './userReducer'
import { QueryReturnValue } from 'node_modules/@reduxjs/toolkit/dist/query/baseQueryTypes'
const baseQuery = fetchBaseQuery({
baseUrl: BACKEND_URL,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).user.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
})
const baseQueryWithReauth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
args,
api,
extraOptions
) => {
let result = await baseQuery(args, api, extraOptions)
const token = (api.getState() as RootState).user.token
if (token && result.error?.status === 401) {
const refreshedToken = (await baseQuery(
{ url: '/refreshToken', method: 'POST', body: { token } },
api,
extraOptions
)) as QueryReturnValue<{ token: string }, FetchBaseQueryError, FetchBaseQueryMeta>
if (refreshedToken?.data?.token) {
api.dispatch(setToken(refreshedToken.data.token))
result = await baseQuery(args, api, extraOptions)
} else {
api.dispatch(clearUser())
localStorage.clear()
window.location.replace('/login?expired=true')
}
}
return result
}
export const api = createApi({
reducerPath: 'api',
@@ -17,15 +62,6 @@ export const api = createApi({
'timebound',
'timebounds',
],
baseQuery: fetchBaseQuery({
baseUrl: BACKEND_URL,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as RootState).user.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
},
}),
baseQuery: baseQueryWithReauth,
endpoints: () => ({}),
})
+4 -1
View File
@@ -60,12 +60,15 @@ const userSlice = createSlice({
setUser(_state, action: PayloadAction<UserState>) {
return { ...action.payload }
},
setToken(state, action: PayloadAction<string>) {
return { ...state, token: action.payload }
},
clearUser() {
return { ...initialState }
},
},
})
export const { setUser, clearUser } = userSlice.actions
export const { setUser, setToken, clearUser } = userSlice.actions
export const userReducer = userSlice.reducer
export const { useTryLoginMutation, useChangePasswordMutation } = userApi