diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 43bd108..d9a57a5 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -2,6 +2,7 @@ import path from 'path'; export interface IConfig { root: string; + https: boolean; db: { host: string; user?: string; @@ -27,6 +28,7 @@ export interface IConfig { const config: IConfig = { env: process.env.NODE_ENV || 'development', + https: process.env.HTTPS === 'true' || process.env.NODE_ENV === 'production', root: path.join(__dirname, '/..'), port: process.env.PORT || '8000', db: { diff --git a/api/src/controllers/auth/auth.ts b/api/src/controllers/auth/auth.ts index 301edaa..0aed78b 100644 --- a/api/src/controllers/auth/auth.ts +++ b/api/src/controllers/auth/auth.ts @@ -5,20 +5,57 @@ import APIError from '../../helpers/APIError'; import User from '../../models/User'; import { sendMagicLinkEmail } from '../../helpers/Mailer'; import config from '../../config/env'; -import { RequestHandler } from 'express'; +import { CookieOptions, Request, RequestHandler, Response } from 'express'; import logger from '../../config/winston'; const verifyJwt = util.promisify(jwt.verify) as any; +const ONE_HOUR = 60 * 60 * 1000; +const TOKEN_EXPIRY = ONE_HOUR * 24 * 30; + +const generateCsrf = () => { + const possible = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let text = ''; + + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + return text; +}; + +const getCookieOpts = ({ + httpOnly = false, + maxAge = TOKEN_EXPIRY, +} = {}): CookieOptions => ({ + maxAge, + httpOnly, + sameSite: 'lax', + secure: config.https, +}); + +const setCookies = (req: Request, res: Response, accessToken: string) => { + const now = Date.now(); + const cookieInfo = JSON.stringify({ + expires: now + TOKEN_EXPIRY, + issued: now, + }); + + res.cookie('access_token', accessToken, getCookieOpts({ httpOnly: true })); + res.cookie('token_info', cookieInfo, getCookieOpts()); + res.cookie('csrf_token', generateCsrf(), getCookieOpts()); +}; export const passwordLoginCallback: RequestHandler = async (req, res) => { const user = req.user as any; const result = signJwt(user.id, user.email); + setCookies(req, res, result.token); + res.json(result); }; const signJwt = (id: string, email: string) => { - const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 5; // 5 hours + const exp = Math.floor((Date.now() + TOKEN_EXPIRY) / 1000); const token = jwt.sign( { id, @@ -48,6 +85,8 @@ export const register: RequestHandler = async (req, res) => { const result = signJwt(u.id, u.email); + setCookies(req, res, result.token); + res.json(result); }; @@ -86,6 +125,14 @@ export const login: RequestHandler = async (req, res) => { res.json({ success: true }); }; +export const logout: RequestHandler = async (req, res) => { + res.clearCookie('access_token'); + res.clearCookie('token_info'); + res.clearCookie('csrf_token'); + + res.json({ success: true }); +}; + export const callback: RequestHandler = async (req, res) => { const { token, password } = req.body; try { @@ -103,6 +150,8 @@ export const callback: RequestHandler = async (req, res) => { const result = signJwt(user.id, user.email); + setCookies(req, res, result.token); + res.json(result); } catch (err) { logger.error(err); diff --git a/api/src/controllers/auth/index.ts b/api/src/controllers/auth/index.ts index 3c8c791..e578ed8 100644 --- a/api/src/controllers/auth/index.ts +++ b/api/src/controllers/auth/index.ts @@ -10,6 +10,7 @@ const router = express.Router(); // eslint-disable-line new-cap router .route('/login') .post(validate(userValidation.login), asyncMiddleware(auth.login)); +router.post('/logout', asyncMiddleware(auth.logout)); router .route('/callback') .post(validate(userValidation.callback), asyncMiddleware(auth.callback)); diff --git a/api/src/controllers/user/index.ts b/api/src/controllers/user/index.ts index 1ec3fa5..9bb0332 100644 --- a/api/src/controllers/user/index.ts +++ b/api/src/controllers/user/index.ts @@ -5,6 +5,8 @@ import * as userCtrl from './user'; import config from '../../config/env'; import asyncMiddleware from '../../middleware/async'; import userValidation from './validation'; +import APIError from '../../helpers/APIError'; +import httpStatus from 'http-status'; const router = express.Router(); // eslint-disable-line new-cap @@ -12,6 +14,29 @@ const requireAuth = expressjwt({ secret: config.jwtSecret, algorithms: ['HS256'], requestProperty: 'user', + getToken: (req) => { + if (req.headers.authorization) { + const [scheme, token] = req.headers.authorization.split(' '); + if (scheme === 'Bearer') { + return token; + } + } + if (req.cookies && req.cookies.access_token) { + if (req.method !== 'GET') { + const csrfToken = req.cookies.csrf_token; + + if (!csrfToken || req.headers['x-csrf-token'] !== csrfToken) { + throw new APIError( + 'Invalid CSRF token', + httpStatus.BAD_REQUEST, + true + ); + } + } + return req.cookies.access_token; + } + return null; + }, }); router diff --git a/frontend/package.json b/frontend/package.json index c96ee4f..8d25a32 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "axios": "^0.27.2", "classnames": "^2.3.2", "http-proxy-middleware": "^2.0.6", + "js-cookie": "^3.0.5", "next": "^13.4.10", "qs": "^6.11.2", "react": "^18.2.0", @@ -47,6 +48,7 @@ }, "devDependencies": { "@types/classnames": "^2.3.1", + "@types/js-cookie": "^3.0.3", "@types/qs": "^6.9.7", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index e584646..f7f5ed3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,5 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; +import Cookies from 'js-cookie'; const apiUrl = typeof window === 'undefined' @@ -8,15 +9,49 @@ const apiUrl = : '/api'; export const api = axios.create({ + xsrfCookieName: 'csrf_token', + xsrfHeaderName: 'x-csrf-token', baseURL: apiUrl, }); -const getHeaders = () => { - const token = localStorage.getItem('token'); +type TokenInfo = { expires: number; issued: number } | undefined; - return { Authorization: `Bearer ${token}` }; +export const getTokenInfo = () => { + const tokenInfo = Cookies.get('token_info'); + + if (tokenInfo) { + try { + const result = JSON.parse(tokenInfo) as TokenInfo; + + return result; + } catch (e) { + // Ignore + } + } + + return undefined; }; +api.interceptors.response.use( + (res) => res, + async (error) => { + // Logout on UNAUTHORIZED unless login route + const url = (error as AxiosError)?.response?.config?.url; + if ( + (error as AxiosError)?.response?.status === 401 && + !url.startsWith('/auth/') + ) { + if (getTokenInfo()) { + await api.post('/auth/logout'); + window.location.href = '/login'; + } + return Promise.reject(error); + } else { + return Promise.reject(error); + } + } +); + export const getUser = (permalink: string) => api.get(`/user/${permalink}`); export const login = (email: string) => api.post('/auth/login', { email }); @@ -24,58 +59,26 @@ export const login = (email: string) => api.post('/auth/login', { email }); export const verifyToken = (token: string, password?: string) => api.post('/auth/callback', { token, password }); -export const accountDetails = () => { - return axios.get('/api/user', { - headers: getHeaders(), - }); -}; +export const accountDetails = () => api.get('/user'); -export const setDetails = (details) => { - return axios.post('/api/user', details, { - headers: getHeaders(), - }); -}; +export const setDetails = (details) => api.post('/user', details); -export const getAvailableProviders = () => { - return axios.get('/api/user/available-providers', { - headers: getHeaders(), - }); -}; -export const getUserProvider = (provider: number | string) => { - return axios.get(`/api/user/provider/${provider}`, { - headers: getHeaders(), - }); -}; -export const saveProvider = (provider: number | string, permalink: any) => { - return axios.put( - `/api/user/provider/${provider}`, - { permalink }, - { - headers: getHeaders(), - } - ); -}; -export const addProvider = (provider: number | string) => { - return axios.post(`/api/user/provider/${provider}`, null, { - headers: getHeaders(), - }); -}; -export const removeProvider = (provider: number | string) => { - return axios.delete(`/api/user/provider/${provider}`, { - headers: getHeaders(), - }); -}; +export const getAvailableProviders = () => api.get('/user/available-providers'); + +export const getUserProvider = (provider: number | string) => + api.get(`/user/provider/${provider}`); + +export const saveProvider = (provider: number | string, permalink: any) => + api.put(`/user/provider/${provider}`, { permalink }); + +export const addProvider = (provider: number | string) => + api.post(`/user/provider/${provider}`); + +export const removeProvider = (provider: number | string) => + api.delete(`/user/provider/${provider}`); export const updateProviderOrder = ( provider: number | string, oldIndex: number, newIndex: number -) => { - return axios.put( - `/api/user/provider/${provider}/order`, - { oldIndex, newIndex }, - { - headers: getHeaders(), - } - ); -}; +) => api.put(`/user/provider/${provider}/order`, { oldIndex, newIndex }); diff --git a/frontend/src/components/AuthForm.tsx b/frontend/src/components/AuthForm.tsx index f9a5b96..4e3ee3f 100644 --- a/frontend/src/components/AuthForm.tsx +++ b/frontend/src/components/AuthForm.tsx @@ -35,8 +35,7 @@ const AuthForm = () => { }; const handleLogin = (res: any) => { - if (res?.data?.token) { - localStorage.setItem('token', res.data.token); + if (res?.data) { router.push('/account'); } }; diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index cd836b5..a411ca0 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -17,7 +17,7 @@ const Header: React.FC = () => { const router = useRouter(); useEffect(() => { - if (user || !localStorage.getItem('token')) { + if (user || !API.getTokenInfo()) { return; } API.accountDetails() @@ -28,8 +28,9 @@ const Header: React.FC = () => { const onLogout = () => { setShowMenu(false); setUser(null); - router.push('/'); - localStorage.removeItem('token'); + API.api.post('/auth/logout').then(() => { + window.location.href = '/?logout=true'; + }); }; const links = [ diff --git a/frontend/src/views/Home/Home.tsx b/frontend/src/views/Home/Home.tsx index 367bf2f..70a0754 100644 --- a/frontend/src/views/Home/Home.tsx +++ b/frontend/src/views/Home/Home.tsx @@ -26,7 +26,7 @@ const Home: React.FC = ({ providers }: any) => { const [urlEntry, setUrlEntry] = useState(''); const router = useRouter(); useEffect(() => { - if (!localStorage.getItem('token')) { + if (!API.getTokenInfo()) { setLoading(false); return; } diff --git a/frontend/src/views/LoginCallback/LoginCallback.tsx b/frontend/src/views/LoginCallback/LoginCallback.tsx index 19be91c..7d82520 100644 --- a/frontend/src/views/LoginCallback/LoginCallback.tsx +++ b/frontend/src/views/LoginCallback/LoginCallback.tsx @@ -19,8 +19,7 @@ const LoginCallback = () => { } verifyToken(token) - .then(({ data }) => { - localStorage.setItem('token', data.token); + .then(() => { setSuccess(true); }) .catch((err) => setError(err.response.data.message)); @@ -52,8 +51,7 @@ const LoginCallback = () => { onSubmit={(e) => { e.preventDefault(); verifyToken(token, password) - .then(({ data }) => { - localStorage.setItem('token', data.token); + .then(() => { setSuccess(true); }) .catch((err) => setError(err.response.data.message)); diff --git a/yarn.lock b/yarn.lock index 9ec4bef..56b85ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -591,6 +591,11 @@ dependencies: "@types/node" "*" +"@types/js-cookie@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.3.tgz#d6bfbbdd0c187354ca555213d1962f6d0691ff4e" + integrity sha512-Xe7IImK09HP1sv2M/aI+48a20VX+TdRJucfq4vfRVy6nWN8PYPOEnlMRSgxJAgYQIXJVL8dZ4/ilAM7dWNaOww== + "@types/json-schema@^7.0.9": version "7.0.12" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb" @@ -2473,6 +2478,11 @@ joi@^17.6.0, joi@^17.7.0, joi@^17.9.2: "@sideway/formula" "^3.0.1" "@sideway/pinpoint" "^2.0.0" +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"