converts cookies to httpOnly server side for security

This commit is contained in:
James
2020-07-03 13:08:17 -04:00
parent a3b93ecfc5
commit 2bd5318eb3
19 changed files with 120 additions and 99 deletions

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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
// /////////////////////////////////////

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -6,6 +6,7 @@ const loginHandler = config => async (req, res) => {
try {
const token = await login({
req,
res,
collection: req.collection,
config,
data: req.body,

View 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;

View File

@@ -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;

View File

@@ -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));

View File

@@ -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));

View File

@@ -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(),
},
});
},

View File

@@ -189,6 +189,10 @@ const Routes = () => {
return <Loading />;
}
if (user === undefined) {
return <Loading />;
}
return <Redirect to={`${match.url}/login`} />;
}}
/>

View File

@@ -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>
);
};

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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 }),

View File

@@ -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(),
);