feat: start using refresh tokens and better session handler

This commit is contained in:
canove
2020-09-29 20:30:02 -03:00
parent 3a777dec39
commit c8b4b5bdfe
16 changed files with 209 additions and 46 deletions

View File

@@ -14,6 +14,7 @@
"dependencies": { "dependencies": {
"@sentry/node": "5.22.3", "@sentry/node": "5.22.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"date-fns": "^2.16.1", "date-fns": "^2.16.1",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@@ -34,6 +35,7 @@
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/bluebird": "^3.5.32", "@types/bluebird": "^3.5.32",
"@types/cookie-parser": "^1.4.2",
"@types/cors": "^2.8.7", "@types/cors": "^2.8.7",
"@types/express": "^4.17.8", "@types/express": "^4.17.8",
"@types/jsonwebtoken": "^8.5.0", "@types/jsonwebtoken": "^8.5.0",

View File

@@ -1,4 +1,6 @@
export default { export default {
secret: "mysecret", secret: "mysecret",
expiresIn: "7d" expiresIn: "15m",
refreshSecret: "myanothersecret",
refreshExpiresIn: "7d"
}; };

View File

@@ -1,11 +1,19 @@
import { Request, Response } from "express"; import { Request, Response } from "express";
import AppError from "../errors/AppError";
import AuthUserService from "../services/UserServices/AuthUserSerice"; import AuthUserService from "../services/UserServices/AuthUserSerice";
import { SendRefreshToken } from "../helpers/SendRefreshToken";
import { RefreshTokenService } from "../services/AuthServices/RefreshTokenService";
export const store = async (req: Request, res: Response): Promise<Response> => { export const store = async (req: Request, res: Response): Promise<Response> => {
const { email, password } = req.body; const { email, password } = req.body;
const { token, user } = await AuthUserService({ email, password }); const { token, user, refreshToken } = await AuthUserService({
email,
password
});
SendRefreshToken(res, refreshToken);
return res.status(200).json({ return res.status(200).json({
token, token,
@@ -14,3 +22,20 @@ export const store = async (req: Request, res: Response): Promise<Response> => {
userId: user.id userId: user.id
}); });
}; };
export const update = async (
req: Request,
res: Response
): Promise<Response> => {
const token: string = req.cookies.jrt;
if (!token) {
throw new AppError("Invalid session. Please login.", 401);
}
const { newToken, refreshToken } = await RefreshTokenService(token);
SendRefreshToken(res, refreshToken);
return res.json({ token: newToken });
};

View File

@@ -0,0 +1,15 @@
import { QueryInterface, DataTypes } from "sequelize";
module.exports = {
up: (queryInterface: QueryInterface) => {
return queryInterface.addColumn("Users", "tokenVersion", {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
});
},
down: (queryInterface: QueryInterface) => {
return queryInterface.removeColumn("Users", "tokenVersion");
}
};

View File

@@ -0,0 +1,23 @@
import { sign } from "jsonwebtoken";
import authConfig from "../config/auth";
import User from "../models/User";
export const createAccessToken = (user: User): string => {
const { secret, expiresIn } = authConfig;
return sign(
{ usarname: user.name, profile: user.profile, id: user.id },
secret,
{
expiresIn
}
);
};
export const createRefreshToken = (user: User): string => {
const { refreshSecret, refreshExpiresIn } = authConfig;
return sign({ id: user.id, tokenVersion: user.tokenVersion }, refreshSecret, {
expiresIn: refreshExpiresIn
});
};

View File

@@ -0,0 +1,5 @@
import { Response } from "express";
export const SendRefreshToken = (res: Response, token: string): void => {
res.cookie("jrt", token, { httpOnly: true });
};

View File

@@ -16,7 +16,7 @@ const isAuth = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader) { if (!authHeader) {
throw new AppError("Token not provided.", 403); throw new AppError("Token was not provided.", 403);
} }
const [, token] = authHeader.split(" "); const [, token] = authHeader.split(" ");

View File

@@ -34,6 +34,10 @@ class User extends Model<User> {
@Column @Column
passwordHash: string; passwordHash: string;
@Default(0)
@Column
tokenVersion: number;
@Default("admin") @Default("admin")
@Column @Column
profile: string; profile: string;

View File

@@ -1,6 +1,5 @@
import { Router, Request, Response } from "express"; import { Router } from "express";
import * as SessionController from "../controllers/SessionController"; import * as SessionController from "../controllers/SessionController";
import isAuth from "../middleware/isAuth";
import * as UserController from "../controllers/UserController"; import * as UserController from "../controllers/UserController";
const authRoutes = Router(); const authRoutes = Router();
@@ -9,8 +8,6 @@ authRoutes.post("/signup", UserController.store);
authRoutes.post("/login", SessionController.store); authRoutes.post("/login", SessionController.store);
authRoutes.get("/check", isAuth, (req: Request, res: Response) => { authRoutes.post("/refresh_token", SessionController.update);
res.status(200).json({ authenticated: true });
});
export default authRoutes; export default authRoutes;

View File

@@ -3,6 +3,7 @@ import "dotenv/config";
import "express-async-errors"; import "express-async-errors";
import express, { Request, Response, NextFunction } from "express"; import express, { Request, Response, NextFunction } from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import multer from "multer"; import multer from "multer";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
@@ -18,7 +19,13 @@ Sentry.init({ dsn: process.env.SENTRY_DSN });
const upload = multer(uploadConfig); const upload = multer(uploadConfig);
const app = express(); const app = express();
app.use(cors()); app.use(
cors({
credentials: true,
origin: process.env.FRONTEND_URL
})
);
app.use(cookieParser());
app.use(express.json()); app.use(express.json());
app.use(Sentry.Handlers.requestHandler()); app.use(Sentry.Handlers.requestHandler());
app.use(upload.single("media")); app.use(upload.single("media"));

View File

@@ -0,0 +1,47 @@
import { verify } from "jsonwebtoken";
import AppError from "../../errors/AppError";
import ShowUserService from "../UserServices/ShowUserService";
import authConfig from "../../config/auth";
import {
createAccessToken,
createRefreshToken
} from "../../helpers/CreateTokens";
interface RefreshTokenPayload {
id: string;
tokenVersion: number;
}
interface Response {
newToken: string;
refreshToken: string;
}
export const RefreshTokenService = async (token: string): Promise<Response> => {
let decoded;
console.log(token);
try {
decoded = verify(token, authConfig.refreshSecret);
} catch (err) {
throw new AppError("Session expire. Please login.", 401);
}
const { id, tokenVersion } = decoded as RefreshTokenPayload;
const user = await ShowUserService(id);
if (!user) {
throw new AppError("No user found with this ID.", 401);
}
if (user.tokenVersion !== tokenVersion) {
throw new AppError("Session revoked. Please login.", 401);
}
const newToken = createAccessToken(user);
const refreshToken = createRefreshToken(user);
return { newToken, refreshToken };
};

View File

@@ -1,8 +1,9 @@
import { sign } from "jsonwebtoken";
import User from "../../models/User"; import User from "../../models/User";
import AppError from "../../errors/AppError"; import AppError from "../../errors/AppError";
import authConfig from "../../config/auth"; import {
createAccessToken,
createRefreshToken
} from "../../helpers/CreateTokens";
interface Request { interface Request {
email: string; email: string;
@@ -12,6 +13,7 @@ interface Request {
interface Response { interface Response {
user: User; user: User;
token: string; token: string;
refreshToken: string;
} }
const AuthUserService = async ({ const AuthUserService = async ({
@@ -30,18 +32,13 @@ const AuthUserService = async ({
throw new AppError("Incorrect user/password combination.", 401); throw new AppError("Incorrect user/password combination.", 401);
} }
const { secret, expiresIn } = authConfig; const token = createAccessToken(user);
const token = sign( const refreshToken = createRefreshToken(user);
{ usarname: user.name, profile: user.profile, id: user.id },
secret,
{
expiresIn
}
);
return { return {
user, user,
token token,
refreshToken
}; };
}; };

View File

@@ -5,7 +5,7 @@ const ShowUserService = async (
id: string | number id: string | number
): Promise<User | undefined> => { ): Promise<User | undefined> => {
const user = await User.findByPk(id, { const user = await User.findByPk(id, {
attributes: ["name", "id", "email", "profile"] attributes: ["name", "id", "email", "profile", "tokenVersion"]
}); });
if (!user) { if (!user) {

View File

@@ -182,6 +182,13 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/cookie-parser@^1.4.2":
version "1.4.2"
resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5"
integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==
dependencies:
"@types/express" "*"
"@types/cors@^2.8.7": "@types/cors@^2.8.7":
version "2.8.7" version "2.8.7"
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.7.tgz#ab2f47f1cba93bce27dfd3639b006cc0e5600889" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.7.tgz#ab2f47f1cba93bce27dfd3639b006cc0e5600889"
@@ -939,6 +946,14 @@ content-type@~1.0.4:
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
cookie-parser@^1.4.5:
version "1.4.5"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49"
integrity sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==
dependencies:
cookie "0.4.0"
cookie-signature "1.0.6"
cookie-signature@1.0.6: cookie-signature@1.0.6:
version "1.0.6" version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"

View File

@@ -13,38 +13,58 @@ const useAuth = () => {
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (token) { if (token) {
api.defaults.headers.Authorization = `Bearer ${JSON.parse(token)}`; api.defaults.headers.Authorization = `Bearer ${JSON.parse(token)}`;
setIsAuth(true); setIsAuth(true);
setLoading(false);
} }
const checkAuth = async () => { setLoading(false);
if (
history.location.pathname === "/login" || api.interceptors.request.use(
history.location.pathname === "/signup" config => {
) { const token = localStorage.getItem("token");
setLoading(false); if (token) {
return; config.headers["Authorization"] = `Bearer ${JSON.parse(token)}`;
}
try {
const res = await api.get("/auth/check");
if (res.status === 200) {
setIsAuth(true);
setLoading(false);
}
} catch (err) {
setLoading(false);
setIsAuth(false);
console.log(err);
if (err.response && err.response.data && err.response.data.error) {
toast.error(err.response.data.error);
} }
setIsAuth(true);
return config;
},
error => {
Promise.reject(error);
} }
}; );
checkAuth();
}, [history.location.pathname]); api.interceptors.response.use(
response => {
return response;
},
async error => {
const originalRequest = error.config;
if (error.response.status === 403 && !originalRequest._retry) {
originalRequest._retry = true;
const { data } = await api.post("/auth/refresh_token");
if (data) {
localStorage.setItem("token", JSON.stringify(data.token));
api.defaults.headers.Authorization = `Bearer ${data.token}`;
}
return api(originalRequest);
}
if (error.response.status === 401) {
localStorage.removeItem("token");
localStorage.removeItem("username");
localStorage.removeItem("profile");
localStorage.removeItem("userId");
api.defaults.headers.Authorization = undefined;
setIsAuth(false);
}
return Promise.reject(error);
}
);
}, []);
const handleLogin = async (e, user) => { const handleLogin = async (e, user) => {
// setLoading(true);
e.preventDefault(); e.preventDefault();
try { try {
const { data } = await api.post("/auth/login", user); const { data } = await api.post("/auth/login", user);
@@ -55,6 +75,7 @@ const useAuth = () => {
api.defaults.headers.Authorization = `Bearer ${data.token}`; api.defaults.headers.Authorization = `Bearer ${data.token}`;
setIsAuth(true); setIsAuth(true);
toast.success(i18n.t("auth.toasts.success")); toast.success(i18n.t("auth.toasts.success"));
// setLoading(false);
history.push("/tickets"); history.push("/tickets");
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -65,6 +86,7 @@ const useAuth = () => {
}; };
const handleLogout = e => { const handleLogout = e => {
// setLoading(true);
e.preventDefault(); e.preventDefault();
setIsAuth(false); setIsAuth(false);
localStorage.removeItem("token"); localStorage.removeItem("token");
@@ -72,6 +94,7 @@ const useAuth = () => {
localStorage.removeItem("profile"); localStorage.removeItem("profile");
localStorage.removeItem("userId"); localStorage.removeItem("userId");
api.defaults.headers.Authorization = undefined; api.defaults.headers.Authorization = undefined;
// setLoading(false);
history.push("/login"); history.push("/login");
}; };

View File

@@ -2,6 +2,7 @@ import axios from "axios";
const api = axios.create({ const api = axios.create({
baseURL: process.env.REACT_APP_BACKEND_URL, baseURL: process.env.REACT_APP_BACKEND_URL,
withCredentials: true,
}); });
export default api; export default api;