From c8b4b5bdfe49650d7987f01400a0f50356c9d28d Mon Sep 17 00:00:00 2001 From: canove Date: Tue, 29 Sep 2020 20:30:02 -0300 Subject: [PATCH] feat: start using refresh tokens and better session handler --- backend/package.json | 2 + backend/src/config/auth.ts | 4 +- backend/src/controllers/SessionController.ts | 27 ++++++- ...0929145451-add-user-tokenVersion-column.ts | 15 ++++ backend/src/helpers/CreateTokens.ts | 23 ++++++ backend/src/helpers/SendRefreshToken.ts | 5 ++ backend/src/middleware/isAuth.ts | 2 +- backend/src/models/User.ts | 4 ++ backend/src/routes/authRoutes.ts | 7 +- backend/src/server.ts | 9 ++- .../AuthServices/RefreshTokenService.ts | 47 ++++++++++++ .../services/UserServices/AuthUserSerice.ts | 21 +++--- .../services/UserServices/ShowUserService.ts | 2 +- backend/yarn.lock | 15 ++++ frontend/src/context/Auth/useAuth.js | 71 ++++++++++++------- frontend/src/services/api.js | 1 + 16 files changed, 209 insertions(+), 46 deletions(-) create mode 100644 backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts create mode 100644 backend/src/helpers/CreateTokens.ts create mode 100644 backend/src/helpers/SendRefreshToken.ts create mode 100644 backend/src/services/AuthServices/RefreshTokenService.ts diff --git a/backend/package.json b/backend/package.json index 95b0891..50fc0b0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@sentry/node": "5.22.3", "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.5", "cors": "^2.8.5", "date-fns": "^2.16.1", "dotenv": "^8.2.0", @@ -34,6 +35,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.2", "@types/bluebird": "^3.5.32", + "@types/cookie-parser": "^1.4.2", "@types/cors": "^2.8.7", "@types/express": "^4.17.8", "@types/jsonwebtoken": "^8.5.0", diff --git a/backend/src/config/auth.ts b/backend/src/config/auth.ts index a0f3b9e..978ea08 100644 --- a/backend/src/config/auth.ts +++ b/backend/src/config/auth.ts @@ -1,4 +1,6 @@ export default { secret: "mysecret", - expiresIn: "7d" + expiresIn: "15m", + refreshSecret: "myanothersecret", + refreshExpiresIn: "7d" }; diff --git a/backend/src/controllers/SessionController.ts b/backend/src/controllers/SessionController.ts index 89a44fb..2c2a3ec 100644 --- a/backend/src/controllers/SessionController.ts +++ b/backend/src/controllers/SessionController.ts @@ -1,11 +1,19 @@ import { Request, Response } from "express"; +import AppError from "../errors/AppError"; 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 => { 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({ token, @@ -14,3 +22,20 @@ export const store = async (req: Request, res: Response): Promise => { userId: user.id }); }; + +export const update = async ( + req: Request, + res: Response +): Promise => { + 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 }); +}; diff --git a/backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts b/backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts new file mode 100644 index 0000000..ceb5c21 --- /dev/null +++ b/backend/src/database/migrations/20200929145451-add-user-tokenVersion-column.ts @@ -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"); + } +}; diff --git a/backend/src/helpers/CreateTokens.ts b/backend/src/helpers/CreateTokens.ts new file mode 100644 index 0000000..ab9e19b --- /dev/null +++ b/backend/src/helpers/CreateTokens.ts @@ -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 + }); +}; diff --git a/backend/src/helpers/SendRefreshToken.ts b/backend/src/helpers/SendRefreshToken.ts new file mode 100644 index 0000000..4e4459a --- /dev/null +++ b/backend/src/helpers/SendRefreshToken.ts @@ -0,0 +1,5 @@ +import { Response } from "express"; + +export const SendRefreshToken = (res: Response, token: string): void => { + res.cookie("jrt", token, { httpOnly: true }); +}; diff --git a/backend/src/middleware/isAuth.ts b/backend/src/middleware/isAuth.ts index 1aa6218..ca1637b 100644 --- a/backend/src/middleware/isAuth.ts +++ b/backend/src/middleware/isAuth.ts @@ -16,7 +16,7 @@ const isAuth = (req: Request, res: Response, next: NextFunction): void => { const authHeader = req.headers.authorization; if (!authHeader) { - throw new AppError("Token not provided.", 403); + throw new AppError("Token was not provided.", 403); } const [, token] = authHeader.split(" "); diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts index e01407b..fe840fc 100644 --- a/backend/src/models/User.ts +++ b/backend/src/models/User.ts @@ -34,6 +34,10 @@ class User extends Model { @Column passwordHash: string; + @Default(0) + @Column + tokenVersion: number; + @Default("admin") @Column profile: string; diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts index b97ab50..e59bbde 100644 --- a/backend/src/routes/authRoutes.ts +++ b/backend/src/routes/authRoutes.ts @@ -1,6 +1,5 @@ -import { Router, Request, Response } from "express"; +import { Router } from "express"; import * as SessionController from "../controllers/SessionController"; -import isAuth from "../middleware/isAuth"; import * as UserController from "../controllers/UserController"; const authRoutes = Router(); @@ -9,8 +8,6 @@ authRoutes.post("/signup", UserController.store); authRoutes.post("/login", SessionController.store); -authRoutes.get("/check", isAuth, (req: Request, res: Response) => { - res.status(200).json({ authenticated: true }); -}); +authRoutes.post("/refresh_token", SessionController.update); export default authRoutes; diff --git a/backend/src/server.ts b/backend/src/server.ts index b023c28..1225df6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -3,6 +3,7 @@ import "dotenv/config"; import "express-async-errors"; import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; +import cookieParser from "cookie-parser"; import multer from "multer"; import * as Sentry from "@sentry/node"; @@ -18,7 +19,13 @@ Sentry.init({ dsn: process.env.SENTRY_DSN }); const upload = multer(uploadConfig); 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(Sentry.Handlers.requestHandler()); app.use(upload.single("media")); diff --git a/backend/src/services/AuthServices/RefreshTokenService.ts b/backend/src/services/AuthServices/RefreshTokenService.ts new file mode 100644 index 0000000..216c459 --- /dev/null +++ b/backend/src/services/AuthServices/RefreshTokenService.ts @@ -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 => { + 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 }; +}; diff --git a/backend/src/services/UserServices/AuthUserSerice.ts b/backend/src/services/UserServices/AuthUserSerice.ts index a6e36d3..a1e21dc 100644 --- a/backend/src/services/UserServices/AuthUserSerice.ts +++ b/backend/src/services/UserServices/AuthUserSerice.ts @@ -1,8 +1,9 @@ -import { sign } from "jsonwebtoken"; - import User from "../../models/User"; import AppError from "../../errors/AppError"; -import authConfig from "../../config/auth"; +import { + createAccessToken, + createRefreshToken +} from "../../helpers/CreateTokens"; interface Request { email: string; @@ -12,6 +13,7 @@ interface Request { interface Response { user: User; token: string; + refreshToken: string; } const AuthUserService = async ({ @@ -30,18 +32,13 @@ const AuthUserService = async ({ throw new AppError("Incorrect user/password combination.", 401); } - const { secret, expiresIn } = authConfig; - const token = sign( - { usarname: user.name, profile: user.profile, id: user.id }, - secret, - { - expiresIn - } - ); + const token = createAccessToken(user); + const refreshToken = createRefreshToken(user); return { user, - token + token, + refreshToken }; }; diff --git a/backend/src/services/UserServices/ShowUserService.ts b/backend/src/services/UserServices/ShowUserService.ts index 4581e98..63d50cc 100644 --- a/backend/src/services/UserServices/ShowUserService.ts +++ b/backend/src/services/UserServices/ShowUserService.ts @@ -5,7 +5,7 @@ const ShowUserService = async ( id: string | number ): Promise => { const user = await User.findByPk(id, { - attributes: ["name", "id", "email", "profile"] + attributes: ["name", "id", "email", "profile", "tokenVersion"] }); if (!user) { diff --git a/backend/yarn.lock b/backend/yarn.lock index 012bfb5..3121ac8 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -182,6 +182,13 @@ dependencies: "@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": version "2.8.7" 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" 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: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" diff --git a/frontend/src/context/Auth/useAuth.js b/frontend/src/context/Auth/useAuth.js index 327727c..ae8301a 100644 --- a/frontend/src/context/Auth/useAuth.js +++ b/frontend/src/context/Auth/useAuth.js @@ -13,38 +13,58 @@ const useAuth = () => { useEffect(() => { const token = localStorage.getItem("token"); - if (token) { api.defaults.headers.Authorization = `Bearer ${JSON.parse(token)}`; setIsAuth(true); + setLoading(false); } - const checkAuth = async () => { - if ( - history.location.pathname === "/login" || - history.location.pathname === "/signup" - ) { - setLoading(false); - return; - } - 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); + setLoading(false); + + api.interceptors.request.use( + config => { + const token = localStorage.getItem("token"); + if (token) { + config.headers["Authorization"] = `Bearer ${JSON.parse(token)}`; } + 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) => { + // setLoading(true); e.preventDefault(); try { const { data } = await api.post("/auth/login", user); @@ -55,6 +75,7 @@ const useAuth = () => { api.defaults.headers.Authorization = `Bearer ${data.token}`; setIsAuth(true); toast.success(i18n.t("auth.toasts.success")); + // setLoading(false); history.push("/tickets"); } catch (err) { console.log(err); @@ -65,6 +86,7 @@ const useAuth = () => { }; const handleLogout = e => { + // setLoading(true); e.preventDefault(); setIsAuth(false); localStorage.removeItem("token"); @@ -72,6 +94,7 @@ const useAuth = () => { localStorage.removeItem("profile"); localStorage.removeItem("userId"); api.defaults.headers.Authorization = undefined; + // setLoading(false); history.push("/login"); }; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index dfba7a9..209fc12 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -2,6 +2,7 @@ import axios from "axios"; const api = axios.create({ baseURL: process.env.REACT_APP_BACKEND_URL, + withCredentials: true, }); export default api;