mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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: () => ({}),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user