converts cookies to httpOnly server side for security
This commit is contained in:
@@ -72,7 +72,7 @@ describe('Users REST API', () => {
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.token).not.toBeNull();
|
||||
expect(data.refreshedToken).toBeDefined();
|
||||
|
||||
token = data.refreshedToken;
|
||||
});
|
||||
|
||||
@@ -9,9 +9,9 @@ const refreshResolver = (config, collection) => async (_, __, context) => {
|
||||
req: context,
|
||||
};
|
||||
|
||||
const refreshedToken = await refresh(options);
|
||||
const result = await refresh(options);
|
||||
|
||||
return refreshedToken;
|
||||
return result;
|
||||
};
|
||||
|
||||
module.exports = refreshResolver;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Unauthorized, AuthenticationError } = require('../../errors');
|
||||
const { AuthenticationError } = require('../../errors');
|
||||
|
||||
const login = async (args) => {
|
||||
try {
|
||||
@@ -65,6 +65,10 @@ const login = async (args) => {
|
||||
},
|
||||
);
|
||||
|
||||
if (args.res) {
|
||||
args.res.cookie(`${config.cookiePrefix}-token`, token, { path: '/', httpOnly: true });
|
||||
}
|
||||
|
||||
// /////////////////////////////////////
|
||||
// 3. Execute after login hook
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Forbidden } = require('../../errors');
|
||||
|
||||
const refresh = async (args) => {
|
||||
try {
|
||||
@@ -24,6 +25,8 @@ const refresh = async (args) => {
|
||||
const opts = {};
|
||||
opts.expiresIn = options.collection.config.auth.tokenExpiration;
|
||||
|
||||
if (typeof options.authorization !== 'string') throw new Forbidden();
|
||||
|
||||
const token = options.authorization.replace('JWT ', '');
|
||||
const payload = jwt.verify(token, secret, {});
|
||||
delete payload.iat;
|
||||
@@ -44,7 +47,12 @@ const refresh = async (args) => {
|
||||
// 4. Return refreshed token
|
||||
// /////////////////////////////////////
|
||||
|
||||
return refreshedToken;
|
||||
payload.exp = jwt.decode(refreshedToken).exp;
|
||||
|
||||
return {
|
||||
refreshedToken,
|
||||
user: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ const resetPassword = require('./resetPassword');
|
||||
const registerFirstUser = require('./registerFirstUser');
|
||||
const update = require('./update');
|
||||
const policies = require('./policies');
|
||||
const logout = require('./logout');
|
||||
|
||||
module.exports = {
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
refresh,
|
||||
init,
|
||||
|
||||
@@ -6,6 +6,7 @@ const loginHandler = config => async (req, res) => {
|
||||
try {
|
||||
const token = await login({
|
||||
req,
|
||||
res,
|
||||
collection: req.collection,
|
||||
config,
|
||||
data: req.body,
|
||||
|
||||
11
src/auth/requestHandlers/logout.js
Normal file
11
src/auth/requestHandlers/logout.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const logoutHandler = config => async (req, res) => {
|
||||
res.cookie(`${config.cookiePrefix}-token`, '', {
|
||||
expires: new Date(0), httpOnly: true, path: '/', overwrite: true,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Logged out successfully.',
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = logoutHandler;
|
||||
@@ -1,5 +1,29 @@
|
||||
const meHandler = async (req, res) => {
|
||||
return res.status(200).json(req.user);
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const meHandler = async (req, res, next) => {
|
||||
try {
|
||||
if (req.user) {
|
||||
const response = req.user;
|
||||
|
||||
if (req.headers.authorization && req.headers.authorization.indexOf('JWT') === 0) {
|
||||
const token = req.headers.authorization.replace('JWT ', '');
|
||||
if (token) {
|
||||
const decoded = jwt.decode(token);
|
||||
|
||||
if (decoded.exp) {
|
||||
response.exp = decoded.exp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json(response);
|
||||
}
|
||||
return res.status(200).json(null);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
module.exports = meHandler;
|
||||
|
||||
@@ -4,7 +4,7 @@ const { refresh } = require('../operations');
|
||||
|
||||
const refreshHandler = config => async (req, res) => {
|
||||
try {
|
||||
const refreshedToken = await refresh({
|
||||
const result = await refresh({
|
||||
req,
|
||||
collection: req.collection,
|
||||
config,
|
||||
@@ -13,7 +13,7 @@ const refreshHandler = config => async (req, res) => {
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Token refresh successful',
|
||||
refreshedToken,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(error.status || httpStatus.INTERNAL_SERVER_ERROR).json(formatErrorResponse(error));
|
||||
|
||||
@@ -4,6 +4,7 @@ const bindCollectionMiddleware = require('../collections/bindCollection');
|
||||
const {
|
||||
init,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
me,
|
||||
register,
|
||||
@@ -35,6 +36,10 @@ const authRoutes = (collection, config, sendEmail) => {
|
||||
.route(`/${slug}/login`)
|
||||
.post(login(config));
|
||||
|
||||
router
|
||||
.route(`/${slug}/logout`)
|
||||
.get(logout(config));
|
||||
|
||||
router
|
||||
.route(`/${slug}/refresh-token`)
|
||||
.post(refresh(config));
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import Cookies from 'universal-cookie';
|
||||
import qs from 'qs';
|
||||
import config from 'payload/config';
|
||||
|
||||
const { cookiePrefix } = config;
|
||||
const cookieTokenName = `${cookiePrefix}-token`;
|
||||
|
||||
export const getJWTHeader = () => {
|
||||
const cookies = new Cookies();
|
||||
const jwt = cookies.get(cookieTokenName);
|
||||
return jwt ? { Authorization: `JWT ${jwt}` } : {};
|
||||
};
|
||||
|
||||
export const requests = {
|
||||
get: (url, params) => {
|
||||
const query = qs.stringify(params, { addQueryPrefix: true, depth: 10 });
|
||||
return fetch(`${url}${query}`, {
|
||||
headers: {
|
||||
...getJWTHeader(),
|
||||
},
|
||||
});
|
||||
return fetch(`${url}${query}`);
|
||||
},
|
||||
|
||||
post: (url, options = {}) => {
|
||||
@@ -29,7 +14,6 @@ export const requests = {
|
||||
method: 'post',
|
||||
headers: {
|
||||
...headers,
|
||||
...getJWTHeader(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,7 +28,6 @@ export const requests = {
|
||||
method: 'put',
|
||||
headers: {
|
||||
...headers,
|
||||
...getJWTHeader(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,7 +41,6 @@ export const requests = {
|
||||
method: 'delete',
|
||||
headers: {
|
||||
...headers,
|
||||
...getJWTHeader(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -189,6 +189,10 @@ const Routes = () => {
|
||||
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (user === undefined) {
|
||||
return <Loading />;
|
||||
}
|
||||
return <Redirect to={`${match.url}/login`} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, {
|
||||
useState, createContext, useContext, useEffect, useCallback,
|
||||
} from 'react';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import Cookies from 'universal-cookie';
|
||||
import config from 'payload/config';
|
||||
import { useModal } from '@trbl/react-modal';
|
||||
import { requests } from '../../api';
|
||||
@@ -12,7 +11,6 @@ import StayLoggedInModal from '../modals/StayLoggedIn';
|
||||
import useDebounce from '../../hooks/useDebounce';
|
||||
|
||||
const {
|
||||
cookiePrefix,
|
||||
admin: {
|
||||
user: userSlug,
|
||||
},
|
||||
@@ -23,16 +21,12 @@ const {
|
||||
},
|
||||
} = config;
|
||||
|
||||
const cookieTokenName = `${cookiePrefix}-token`;
|
||||
|
||||
const cookies = new Cookies();
|
||||
const Context = createContext({});
|
||||
|
||||
const isNotExpired = decodedJWT => (decodedJWT?.exp || 0) > Date.now() / 1000;
|
||||
|
||||
const UserProvider = ({ children }) => {
|
||||
const [token, setToken] = useState('');
|
||||
const [user, setUser] = useState(null);
|
||||
const [user, setUser] = useState(undefined);
|
||||
const [tokenInMemory, setTokenInMemory] = useState(null);
|
||||
const exp = user?.exp;
|
||||
|
||||
const [permissions, setPermissions] = useState({ canAccessAdmin: null });
|
||||
|
||||
@@ -42,60 +36,56 @@ const UserProvider = ({ children }) => {
|
||||
const [lastLocationChange, setLastLocationChange] = useState(0);
|
||||
const debouncedLocationChange = useDebounce(lastLocationChange, 10000);
|
||||
|
||||
const exp = user?.exp || 0;
|
||||
const email = user?.email;
|
||||
|
||||
const refreshToken = useCallback(() => {
|
||||
// Need to retrieve token straight from cookie so as to keep this function
|
||||
// with no dependencies and to make sure we have the exact token that will be used
|
||||
// in the request to the /refresh route
|
||||
const tokenFromCookie = cookies.get(cookieTokenName);
|
||||
const decodedToken = jwt.decode(tokenFromCookie);
|
||||
const refreshCookie = useCallback(() => {
|
||||
setTimeout(async () => {
|
||||
const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`);
|
||||
|
||||
if (decodedToken?.exp > (Date.now() / 1000)) {
|
||||
setTimeout(async () => {
|
||||
const request = await requests.post(`${serverURL}${api}/${userSlug}/refresh-token`);
|
||||
if (request.status === 200) {
|
||||
const json = await request.json();
|
||||
setUser(json.user);
|
||||
}
|
||||
}, 1000);
|
||||
}, [setUser]);
|
||||
|
||||
if (request.status === 200) {
|
||||
const json = await request.json();
|
||||
setToken(json.refreshedToken);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}, [setToken]);
|
||||
const setToken = useCallback((token) => {
|
||||
const decoded = jwt.decode(token);
|
||||
setUser(decoded);
|
||||
setTokenInMemory(token);
|
||||
}, []);
|
||||
|
||||
const logOut = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
cookies.remove(cookieTokenName, { path: '/' });
|
||||
setTokenInMemory(null);
|
||||
requests.get(`${serverURL}${api}/${userSlug}/logout`);
|
||||
};
|
||||
|
||||
// On mount, get cookie and set as token
|
||||
// On mount, get user and set
|
||||
useEffect(() => {
|
||||
const cookieToken = cookies.get(cookieTokenName);
|
||||
if (cookieToken) setToken(cookieToken);
|
||||
const fetchMe = async () => {
|
||||
const request = await requests.get(`${serverURL}${api}/${userSlug}/me`);
|
||||
|
||||
if (request.status === 200) {
|
||||
const json = await request.json();
|
||||
setUser(json);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMe();
|
||||
}, []);
|
||||
|
||||
// When location changes, refresh token
|
||||
// When location changes, refresh cookie
|
||||
useEffect(() => {
|
||||
refreshToken();
|
||||
}, [debouncedLocationChange, refreshToken]);
|
||||
if (email) {
|
||||
refreshCookie();
|
||||
}
|
||||
}, [debouncedLocationChange, refreshCookie, email]);
|
||||
|
||||
useEffect(() => {
|
||||
setLastLocationChange(Date.now());
|
||||
}, [pathname]);
|
||||
|
||||
// When token changes, set cookie, decode and set user
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
const decoded = jwt.decode(token);
|
||||
if (isNotExpired(decoded)) {
|
||||
setUser(decoded);
|
||||
cookies.set(cookieTokenName, token, { path: '/' });
|
||||
}
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// When user changes, get new policies
|
||||
useEffect(() => {
|
||||
async function getPermissions() {
|
||||
@@ -149,15 +139,15 @@ const UserProvider = ({ children }) => {
|
||||
return (
|
||||
<Context.Provider value={{
|
||||
user,
|
||||
setToken,
|
||||
logOut,
|
||||
refreshToken,
|
||||
token,
|
||||
refreshCookie,
|
||||
permissions,
|
||||
setToken,
|
||||
token: tokenInMemory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<StayLoggedInModal refreshToken={refreshToken} />
|
||||
<StayLoggedInModal refreshCookie={refreshCookie} />
|
||||
</Context.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ const Form = (props) => {
|
||||
const history = useHistory();
|
||||
const locale = useLocale();
|
||||
const { replaceStatus, addStatus, clearStatus } = useStatusList();
|
||||
const { refreshToken } = useUser();
|
||||
const { refreshCookie } = useUser();
|
||||
|
||||
const contextRef = useRef({ ...initContextState });
|
||||
|
||||
@@ -278,7 +278,7 @@ const Form = (props) => {
|
||||
};
|
||||
|
||||
useThrottledEffect(() => {
|
||||
refreshToken();
|
||||
refreshCookie();
|
||||
}, 15000, [fields]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, {
|
||||
Component, useState, useEffect, useCallback,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Cookies from 'universal-cookie';
|
||||
import some from 'async-some';
|
||||
import config from 'payload/config';
|
||||
import withCondition from '../../withCondition';
|
||||
@@ -14,14 +13,10 @@ import { relationship } from '../../../../../fields/validations';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const cookies = new Cookies();
|
||||
|
||||
const {
|
||||
cookiePrefix, serverURL, routes: { api }, collections,
|
||||
serverURL, routes: { api }, collections,
|
||||
} = config;
|
||||
|
||||
const cookieTokenName = `${cookiePrefix}-token`;
|
||||
|
||||
const maxResultsPerRequest = 10;
|
||||
|
||||
class Relationship extends Component {
|
||||
@@ -62,7 +57,6 @@ class Relationship extends Component {
|
||||
const {
|
||||
relations, lastFullyLoadedRelation, lastLoadedPage, search,
|
||||
} = this.state;
|
||||
const token = cookies.get(cookieTokenName);
|
||||
|
||||
const relationsToSearch = relations.slice(lastFullyLoadedRelation + 1);
|
||||
|
||||
@@ -71,11 +65,7 @@ class Relationship extends Component {
|
||||
const collection = collections.find(coll => coll.slug === relation);
|
||||
const fieldToSearch = collection.useAsTitle || 'id';
|
||||
const searchParam = search ? `&where[${fieldToSearch}][like]=${search}` : '';
|
||||
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPage}${searchParam}`, {
|
||||
headers: {
|
||||
Authorization: `JWT ${token}`,
|
||||
},
|
||||
});
|
||||
const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPage}${searchParam}`);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const baseClass = 'stay-logged-in';
|
||||
const { routes: { admin } } = config;
|
||||
|
||||
const StayLoggedInModal = (props) => {
|
||||
const { refreshToken } = props;
|
||||
const { refreshCookie } = props;
|
||||
const history = useHistory();
|
||||
const { closeAll: closeAllModals } = useModal();
|
||||
|
||||
@@ -36,7 +36,7 @@ const StayLoggedInModal = (props) => {
|
||||
Log out
|
||||
</Button>
|
||||
<Button onClick={() => {
|
||||
refreshToken();
|
||||
refreshCookie();
|
||||
closeAllModals();
|
||||
}}
|
||||
>
|
||||
@@ -49,7 +49,7 @@ const StayLoggedInModal = (props) => {
|
||||
};
|
||||
|
||||
StayLoggedInModal.propTypes = {
|
||||
refreshToken: PropTypes.func.isRequired,
|
||||
refreshCookie: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default StayLoggedInModal;
|
||||
|
||||
@@ -23,7 +23,7 @@ const baseClass = 'create-first-user';
|
||||
const CreateFirstUser = (props) => {
|
||||
const { setInitialized } = props;
|
||||
const { addStatus } = useStatusList();
|
||||
const { setToken } = useUser();
|
||||
const { setCookieToken } = useUser();
|
||||
const history = useHistory();
|
||||
|
||||
const handleAjaxResponse = (res) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ const cookieParser = require('cookie-parser');
|
||||
const qsMiddleware = require('qs-middleware');
|
||||
const fileUpload = require('express-fileupload');
|
||||
const localizationMiddleware = require('../../localization/middleware');
|
||||
const createAuthHeaderFromCookie = require('./createAuthHeaderFromCookie');
|
||||
const authenticate = require('./authenticate');
|
||||
const identifyAPI = require('./identifyAPI');
|
||||
|
||||
@@ -15,11 +16,12 @@ const middleware = (config) => {
|
||||
passport.use(new AnonymousStrategy.Strategy());
|
||||
|
||||
return [
|
||||
cookieParser(),
|
||||
createAuthHeaderFromCookie(config),
|
||||
passport.initialize(),
|
||||
passport.session(),
|
||||
authenticate(config),
|
||||
express.json(),
|
||||
cookieParser(),
|
||||
methodOverride('X-HTTP-Method-Override'),
|
||||
qsMiddleware({ depth: 10 }),
|
||||
bodyParser.urlencoded({ extended: true }),
|
||||
|
||||
@@ -7,7 +7,6 @@ const getConfig = require('./utilities/getConfig');
|
||||
const authenticate = require('./express/middleware/authenticate');
|
||||
const connectMongoose = require('./mongoose/connect');
|
||||
const expressMiddleware = require('./express/middleware');
|
||||
const createAuthHeaderFromCookie = require('./express/middleware/createAuthHeaderFromCookie');
|
||||
const initAdmin = require('./express/admin');
|
||||
const initCollections = require('./collections/init');
|
||||
const initGlobals = require('./globals/init');
|
||||
@@ -55,7 +54,6 @@ class Payload {
|
||||
this.router.use(
|
||||
this.config.routes.graphQL,
|
||||
identifyAPI('GraphQL'),
|
||||
createAuthHeaderFromCookie(this.config),
|
||||
authenticate(this.config),
|
||||
new GraphQL(this).init(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user