Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch to cookie auth #36

Merged
merged 1 commit into from
Jul 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';

export interface IConfig {
root: string;
https: boolean;
db: {
host: string;
user?: string;
Expand All @@ -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: {
Expand Down
53 changes: 51 additions & 2 deletions api/src/controllers/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions api/src/controllers/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
25 changes: 25 additions & 0 deletions api/src/controllers/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,38 @@ 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

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
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
107 changes: 55 additions & 52 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios';
import axios, { AxiosError } from 'axios';
import Cookies from 'js-cookie';

const apiUrl =
typeof window === 'undefined'
Expand All @@ -8,74 +9,76 @@ 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<any>)?.response?.config?.url;
if (
(error as AxiosError<any>)?.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 });

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 });
3 changes: 1 addition & 2 deletions frontend/src/components/AuthForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
};
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const Header: React.FC<Props> = () => {
const router = useRouter();

useEffect(() => {
if (user || !localStorage.getItem('token')) {
if (user || !API.getTokenInfo()) {
return;
}
API.accountDetails()
Expand All @@ -28,8 +28,9 @@ const Header: React.FC<Props> = () => {
const onLogout = () => {
setShowMenu(false);
setUser(null);
router.push('/');
localStorage.removeItem('token');
API.api.post('/auth/logout').then(() => {
window.location.href = '/?logout=true';
});
};

const links = [
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/views/Home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/views/LoginCallback/LoginCallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down