Skip to content

Commit

Permalink
Rework session handling and shrink jwt session cookie
Browse files Browse the repository at this point in the history
  • Loading branch information
blazer82 committed Jul 3, 2024
1 parent 03de190 commit 55d0d0e
Show file tree
Hide file tree
Showing 17 changed files with 127 additions and 86 deletions.
4 changes: 2 additions & 2 deletions components/Layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import AdminNavigation from '../AdminNavigation';
import {logout} from '@/redux/auth/action/logout';
import {useRouter} from 'next/router';
import getConfig from 'next/config';
import {Account} from '@/types/Account';
import {SessionAccount} from '@/types/Account';

const {publicRuntimeConfig} = getConfig();

Expand Down Expand Up @@ -56,7 +56,7 @@ const Layout: React.FunctionComponent<React.PropsWithChildren<LayoutProps>> = ({
}, [user, dispatch]);

const handleAccountSwitch = React.useCallback(
(account: Account) => {
(account: SessionAccount) => {
setAnchorEl(null);
router.push(`${publicRuntimeConfig.appURL}/dashboard/${account._id}`);
},
Expand Down
6 changes: 0 additions & 6 deletions helpers/createJWT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@ const createJWT: CreateJWT = async (user) => {
{
_id,
role: user.role,
email: user.email,
emailVerified: user.emailVerified,
accounts: user.accounts,
maxAccounts: user.maxAccounts,
serverURLOnSignUp: user.serverURLOnSignUp,
timezone: user.timezone,
},
serverRuntimeConfig.jwtSecret,
{expiresIn: '10m'},
Expand Down
31 changes: 31 additions & 0 deletions helpers/createSessionUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {Account, SessionAccount} from '@/types/Account';
import {SessionUser, User} from '@/types/User';

const createSessionAccount = (account: Account): SessionAccount => {
return {
_id: account._id.toString(),
serverURL: account.serverURL,
name: account.name,
username: account.username,
accountName: account.accountName,
accountURL: account.accountURL,
avatarURL: account.avatarURL,
utcOffset: account.utcOffset,
timezone: account.timezone,
};
};

const createSessionUser = (user: User): SessionUser => {
return {
_id: user._id.toString(),
role: user.role,
email: user.email,
emailVerified: user.emailVerified,
accounts: user.accounts?.map(createSessionAccount),
maxAccounts: user.maxAccounts,
serverURLOnSignUp: user.serverURLOnSignUp,
timezone: user.timezone,
};
};

export default createSessionUser;
44 changes: 7 additions & 37 deletions helpers/getAuthInfoFromRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,24 @@ import jwt from 'jsonwebtoken';
import getConfig from 'next/config';
import refreshUser from '@/service/authentication/refreshUser';
import {NextApiRequest} from 'next';
import {User} from '../types/User';
import {JwtUser} from '../types/User';

const {serverRuntimeConfig} = getConfig();

type GetAuthInfoFromRequest = (req: NextApiRequest, forceRefresh?: boolean) => Promise<{user?: User; token?: string; refreshToken?: string}>;
type GetAuthInfoFromRequest = (req: NextApiRequest, forceRefresh?: boolean) => Promise<{user?: JwtUser; token?: string; refreshToken?: string}>;
const getAuthInfoFromRequest: GetAuthInfoFromRequest = async ({cookies, headers}, forceRefresh = false) => {
const token = headers['authorization']?.split(' ')[1] ?? cookies.token ?? '';
try {
if (forceRefresh) {
throw new Error();
}

const {
_id,
role,
email,
emailVerified = false,
accounts = null,
maxAccounts = null,
serverURLOnSignUp = null,
timezone = null,
} = (await jwt.verify(token, serverRuntimeConfig.jwtSecret)) as Partial<User>;
const {_id, role} = (await jwt.verify(token, serverRuntimeConfig.jwtSecret)) as JwtUser;
return {
user: {
_id,
role,
email,
emailVerified,
accounts,
maxAccounts,
serverURLOnSignUp,
timezone,
} as User,
} as JwtUser,
token,
};
} catch (error: any) {
Expand All @@ -43,28 +28,13 @@ const getAuthInfoFromRequest: GetAuthInfoFromRequest = async ({cookies, headers}
if (response === null) {
return {};
}
const {
_id,
role,
email,
emailVerified = false,
accounts = null,
maxAccounts = null,
serverURLOnSignUp = null,
timezone = null,
} = jwt.decode(response.token) as Partial<User>;
const {_id, role} = jwt.decode(response.token) as JwtUser;
return {
...response,
user: {
_id,
role,
email,
emailVerified,
accounts,
maxAccounts,
serverURLOnSignUp,
timezone,
} as User,
...response,
} as JwtUser,
};
} else {
console.warn(error?.message);
Expand Down
43 changes: 33 additions & 10 deletions helpers/handleAuthentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import getAuthInfoFromRequest from '@/helpers/getAuthInfoFromRequest';
import {UserRole} from '@/types/UserRole';
import {AuthResponseType} from '@/types/AuthResponseType';
import {loginSuccessful} from '@/redux/auth/slice';
import UserModel from '@/models/UserModel';
import AccountModel from '@/models/AccountModel';
import UserCredentialsModel from '@/models/UserCredentialsModel';
import {logger} from './logger';
import createSessionUser from './createSessionUser';

type HandleAuthentication = (
roles: UserRole[],
Expand All @@ -13,16 +18,27 @@ type HandleAuthentication = (
) => Promise<{id?: string; role?: string; token?: string}>;

const handleAuthentication: HandleAuthentication = async (roles, type, {store, req, res, forceRefresh = false}) => {
const {user, token, refreshToken} = await getAuthInfoFromRequest(req as NextApiRequest, forceRefresh);
const {user: jwtUser, token, refreshToken} = await getAuthInfoFromRequest(req as NextApiRequest, forceRefresh);

if (refreshToken && token) {
res.setHeader('Set-Cookie', [
serialize('token', token, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
serialize('refreshToken', refreshToken, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
]);
if (!jwtUser || !roles.map((role) => role.toString()).includes(jwtUser.role)) {
switch (type) {
case AuthResponseType.Error:
res.status(401).end();
break;
default:
res.writeHead(302, {Location: '/login'}).end();
break;
}
return {};
}

if (!user || !roles.map((role) => role.toString()).includes(user.role)) {
const user = await UserModel.findById(jwtUser._id).populate([
{path: 'accounts', model: AccountModel, match: {setupComplete: true}},
{path: 'credentials', model: UserCredentialsModel},
]);

if (!user) {
logger.error(`handleAuthentication: User not found: ${jwtUser._id}`);
switch (type) {
case AuthResponseType.Error:
res.status(401).end();
Expand All @@ -32,10 +48,17 @@ const handleAuthentication: HandleAuthentication = async (roles, type, {store, r
break;
}
return {};
} else {
store?.dispatch(loginSuccessful(user));
return {id: user._id, role: user.role, token};
}

if (refreshToken && token) {
res.setHeader('Set-Cookie', [
serialize('token', token, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
serialize('refreshToken', refreshToken, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
]);
}

store?.dispatch(loginSuccessful(createSessionUser(user)));
return {id: user._id, role: user.role, token};
};

export default handleAuthentication;
4 changes: 2 additions & 2 deletions pages/api/user/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ const login: Login = async ({body, method}, res) => {
return res.status(401).end();
}

const {token, refreshToken} = response;
const {token, refreshToken, user} = response;

res.setHeader('Set-Cookie', [
serialize('token', token, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
serialize('refreshToken', refreshToken, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
]);
res.json({token, refreshToken});
res.json({token, refreshToken, user});
};

export default login;
4 changes: 2 additions & 2 deletions pages/api/user/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ const refresh: Refresh = async ({cookies, method}, res) => {
return res.status(400).end();
}

const {token, refreshToken} = response;
const {token, refreshToken, user} = response;

res.setHeader('Set-Cookie', [
serialize('token', token, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
serialize('refreshToken', refreshToken, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
]);
res.json({token, refreshToken});
res.json({token, refreshToken, user});
};

export default refresh;
4 changes: 2 additions & 2 deletions pages/api/user/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ const register: Register = async ({body, method}, res) => {
return res.status(500).end();
}

const {token, refreshToken} = response;
const {token, refreshToken, user: sessionUser} = response;

res.setHeader('Set-Cookie', [
serialize('token', token, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
serialize('refreshToken', refreshToken, {path: '/', httpOnly: true, secure: process.env.NODE_ENV !== 'development'}),
]);
res.json({token, refreshToken});
res.json({token, refreshToken, user: sessionUser});
};

export default register;
5 changes: 1 addition & 4 deletions redux/auth/action/login.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import postJSON from '@/helpers/postJSON';
import {AppDispatch} from '@/redux/store';
import {User} from '@/types/User';
import jwt from 'jsonwebtoken';
import {loginAttempt, loginFailed, loginSuccessful} from '../slice';

export const login = (email: string, password: string) => async (dispatch: AppDispatch) => {
Expand All @@ -10,8 +8,7 @@ export const login = (email: string, password: string) => async (dispatch: AppDi
const response = await postJSON('/api/user/login', {email, password});

if (response.status === 200) {
const {token} = await response.data;
const user = jwt.decode(token) as User;
const {user} = await response.data;
dispatch(loginSuccessful(user));
} else {
dispatch(loginFailed('Failed to log in. User or password invalid. Please try again.'));
Expand Down
4 changes: 2 additions & 2 deletions redux/auth/action/logout.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import postJSON from '@/helpers/postJSON';
import {AppDispatch} from '@/redux/store';
import {User} from '@/types/User';
import {SessionUser} from '@/types/User';
import {logoutSuccessful} from '../slice';

export const logout = (user: User) => async (dispatch: AppDispatch) => {
export const logout = (user: SessionUser) => async (dispatch: AppDispatch) => {
await postJSON('/api/user/logout', user);
dispatch(logoutSuccessful());
};
5 changes: 1 addition & 4 deletions redux/auth/action/refresh.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import get from '@/helpers/get';
import {AppDispatch} from '@/redux/store';
import {User} from '@/types/User';
import jwt from 'jsonwebtoken';
import {loginSuccessful} from '../slice';

export const refresh = () => async (dispatch: AppDispatch) => {
const response = await get('/api/user/refresh');

if (response.status === 200) {
const {token} = await response.data;
const user = jwt.decode(token) as User;
const {user} = await response.data;
dispatch(loginSuccessful(user));
}
};
12 changes: 6 additions & 6 deletions redux/auth/slice.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {createSlice, PayloadAction} from '@reduxjs/toolkit';
import {User} from '@/types/User';
import {SessionUser} from '@/types/User';
import {HYDRATE} from 'next-redux-wrapper';
import {Account} from '@/types/Account';
import {SessionAccount} from '@/types/Account';

// Define a type for the slice state
interface AuthState {
user?: User | null;
account?: Account | null;
user?: SessionUser | null;
account?: SessionAccount | null;
loginInProgress: boolean;
loginError?: string | null;
resetPasswordRequestInProgress?: boolean;
Expand All @@ -27,7 +27,7 @@ export const authSlice = createSlice({
state.loginInProgress = true;
state.loginError = null;
},
loginSuccessful: (state, action: PayloadAction<User>) => {
loginSuccessful: (state, action: PayloadAction<SessionUser>) => {
state.user = action.payload;
state.account = (action.payload.accounts?.length ?? 0) > 0 ? action.payload.accounts![0] : null;
state.loginInProgress = false;
Expand All @@ -41,7 +41,7 @@ export const authSlice = createSlice({
state.user = null;
state.account = null;
},
switchAccount: (state, action: PayloadAction<Account>) => {
switchAccount: (state, action: PayloadAction<SessionAccount>) => {
state.account = action.payload;
},
resetPasswordRequestStarted: (state) => {
Expand Down
5 changes: 1 addition & 4 deletions redux/user/action/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import postJSON from '@/helpers/postJSON';
import {loginSuccessful} from '@/redux/auth/slice';
import {AppDispatch} from '@/redux/store';
import {RegistrationFormData} from '@/schemas/registrationForm';
import {User} from '@/types/User';
import jwt from 'jsonwebtoken';
import {registrationAttempt, registrationFailed} from '../slice';

export const register = (data: RegistrationFormData) => async (dispatch: AppDispatch) => {
Expand All @@ -12,8 +10,7 @@ export const register = (data: RegistrationFormData) => async (dispatch: AppDisp
const response = await postJSON('/api/user/register', data);

if (response.status === 200) {
const {token} = await response.data;
const user = jwt.decode(token) as User;
const {user} = await response.data;
dispatch(loginSuccessful(user));
} else {
const {error} = await response.data;
Expand Down
6 changes: 4 additions & 2 deletions service/authentication/loginUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import dbConnect from '@/helpers/dbConnect';
import UserModel from '@/models/UserModel';
import UserCredentialsModel, {UserCredentials} from '@/models/UserCredentialsModel';
import AccountModel from '@/models/AccountModel';
import {SessionUser} from '@/types/User';
import createSessionUser from '@/helpers/createSessionUser';

type LoginUser = (email: string, password: string) => Promise<{token: string; refreshToken: string} | null>;
type LoginUser = (email: string, password: string) => Promise<{token: string; refreshToken: string; user: SessionUser} | null>;
const loginUser: LoginUser = async (email, password) => {
await dbConnect();

Expand Down Expand Up @@ -35,7 +37,7 @@ const loginUser: LoginUser = async (email, password) => {
user.oldAccountDeletionNoticeSent = false;
await user.save();

return {token, refreshToken};
return {token, refreshToken, user: createSessionUser(user)};
};

export default loginUser;
6 changes: 4 additions & 2 deletions service/authentication/refreshUser.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import createJWT from '@/helpers/createJWT';
import createSessionUser from '@/helpers/createSessionUser';
import dbConnect from '@/helpers/dbConnect';
import AccountModel from '@/models/AccountModel';
import UserCredentialsModel from '@/models/UserCredentialsModel';
import UserModel from '@/models/UserModel';
import {SessionUser} from '@/types/User';

type RefreshUser = (refreshToken: string) => Promise<{token: string; refreshToken: string} | null>;
type RefreshUser = (refreshToken: string) => Promise<{token: string; refreshToken: string; user: SessionUser} | null>;
const refreshUser: RefreshUser = async (currentRefreshToken) => {
await dbConnect();

Expand All @@ -27,7 +29,7 @@ const refreshUser: RefreshUser = async (currentRefreshToken) => {
user.oldAccountDeletionNoticeSent = false;
await user.save();

return {token, refreshToken};
return {token, refreshToken, user: createSessionUser(user)};
};

export default refreshUser;
Loading

0 comments on commit 55d0d0e

Please sign in to comment.