From 08105bd5368f6c0e15a79d77872ceca89dea706c Mon Sep 17 00:00:00 2001 From: Krishna Maurya Date: Mon, 7 Dec 2020 00:15:01 +0530 Subject: [PATCH] refresh token implementation, no token rotation yet, store in secure cookie --- index.js | 22 ++++-- migrations/sqls/20200517131811-authors-up.sql | 2 + src/config/constants.js | 8 +- src/resolvers/authors.js | 79 ++++++++++++++++++- src/resolvers/experiences.js | 2 +- src/schema.js | 6 +- src/types/authors.js | 4 + src/utils/getauthtoken.js | 10 ++- src/utils/getrefreshtoken.js | 13 +++ 9 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 src/utils/getrefreshtoken.js diff --git a/index.js b/index.js index e9b8d90..fbc7aa9 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ console.log(`Loaded environment is ${process.env.NODE_ENV}`); import express from 'express'; import cookieParser from 'cookie-parser'; -import { ApolloServer } from 'apollo-server-express'; +import { ApolloServer, AuthenticationError } from 'apollo-server-express'; import expressPlayground from 'graphql-playground-middleware-express'; import { applyMiddleware } from 'graphql-middleware'; import jwt from 'jsonwebtoken'; @@ -33,25 +33,33 @@ const requestlogging = { const schemaWithMiddleware = applyMiddleware(schema, ...middlewares); -const context = ({ req }) => { +const context = ({ req, res }) => { const token = req.headers.authorization; if (token) { - try { + try { const userAuthData = jwt.verify(token, process.env.JWT_SECRET); - userAuthData.isAuthenticated = true; + userAuthData.req = req; + userAuthData.res = res; return userAuthData; } catch (e) { - console.log('exception', e.message); - return { isAuthenticated: false }; + // do not make a round trip of responding with 401 and then client making the refreshUserToken request + // instead when JWT expired, renew the access token from here + throw new AuthenticationError('Un-authorized'); } } - return { isAuthenticated: false }; + return {req, res}; } const server = new ApolloServer({ schema: schemaWithMiddleware, context, plugins: [],//requestlogging + formatError: (err) => { + if(err instanceof AuthenticationError) { + return { StatusCode: 401 }; + } + return err; + }, onHealthCheck: () => { return new Promise((resolve, reject) => { // Replace the `true` in this conditional with more specific checks! diff --git a/migrations/sqls/20200517131811-authors-up.sql b/migrations/sqls/20200517131811-authors-up.sql index 66224c9..8a899a8 100644 --- a/migrations/sqls/20200517131811-authors-up.sql +++ b/migrations/sqls/20200517131811-authors-up.sql @@ -17,6 +17,8 @@ CREATE TABLE `authors` ( `region` varchar(15) DEFAULT NULL, -- Language/locale for buttons, titles and other text from Facebook for this account on experiences `languages` varchar(200) DEFAULT NULL, + -- refreshtoken storage + `refreshtoken` varchar(50) DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), diff --git a/src/config/constants.js b/src/config/constants.js index b01b7ce..ba2c48b 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -5,6 +5,10 @@ export const SHOW_TO_USER_DATEFORMAT = 'DD MMM YYYY, HH:MM A'; export const SLUG_MAX_LENGTH = 90; -export const FORGOT_PASSWORD_LINK_EXPIRY_TIME = 60; // in minutes +export const FORGOT_PASSWORD_LINK_EXPIRY_TIME = 60; // in minutes, amounts to 1 hour -export const VERIFICATION_LINK_EXPIRY_TIME = 24 * 60 // in minutes \ No newline at end of file +export const VERIFICATION_LINK_EXPIRY_TIME = 24 * 60 // in minutes, amounts to 24 hours + +export const REFRESH_TOKEN_EXPIRY = 3000 * 24 * 60 * 1000 // in seconds, approx 10 years + +export const ACCESS_TOKEN_EXPIRY = 24 * 60 // in minutes, amounts to 1 day \ No newline at end of file diff --git a/src/resolvers/authors.js b/src/resolvers/authors.js index 3e54e38..5d0905a 100644 --- a/src/resolvers/authors.js +++ b/src/resolvers/authors.js @@ -1,9 +1,10 @@ import bcrypt from 'bcrypt'; import mysql from '../connectors/mysql'; import getAuthToken from '../utils/getauthtoken'; +import getRefreshToken from '../utils/getrefreshtoken'; import getAlphanumeric from '../utils/getalphanumeric'; import SendEmailVerificationLink from '../mails/sendemailverificationlink'; -import { EXPERIENCES_PER_PAGE, VERIFICATION_LINK_EXPIRY_TIME } from '../config/constants'; +import { EXPERIENCES_PER_PAGE, VERIFICATION_LINK_EXPIRY_TIME, REFRESH_TOKEN_EXPIRY } from '../config/constants'; import { cursorFormat, createdAtFormat, publishDateFormat, addMinutesInCurrentTime } from '../utils/dateformats'; export const verifyMe = (_, __, context) => { @@ -15,6 +16,41 @@ export const verifyMe = (_, __, context) => { return { valid }; }; +/* + Access Token consist of below property with name as mentioned below + displayname, + email, + authoruid, + languages, + region, + shortintro, + isemailverified +*/ + +export const refreshUserToken = async (_, { uid }, context) => { + const {req} = context; + + const refreshtoken = req.cookies.refreshtoken; + // verify if refreshtoken already exist + const query = ` + SELECT displayname, email, uid as authoruid, languages, region, shortintro, isemailverified + FROM authors + WHERE uid=? AND refreshtoken=? + `; + + const result = await mysql.query(query, [uid, refreshtoken]); + + if(!result && !result[0]) { + return {}; + } + + // if the refreshtoken is valid + const accesstoken = getAuthToken({...result[0]}); + return { + token: accesstoken + }; +} + // first 10 and infinit scroll // get Authors details along both published experiences and unpublished experiences // itsme: true - when author himself visit the page @@ -156,10 +192,25 @@ export const signupAuthor = async (_, { input }, context) => { return { exist }; } +// +const keepRefreshToken = async (refreshtoken, uid) => { + const query = ` + Update authors + SET refreshtoken = ? + WHERE uid = ? + `; + + const result = await mysql.query(query, [refreshtoken, uid]); + + if(!result || !result.affectedRows) { + console.error(`[ERROR] Could not save Refresh token into DB. Need attention`); + } + return result; +} // login export const signinAuthor = async (_, { email, password }, context) => { - + const query = ` SELECT displayname, uid as authoruid, languages, region, shortintro, password, isemailverified FROM authors @@ -181,14 +232,36 @@ export const signinAuthor = async (_, { email, password }, context) => { } const match = await bcrypt.compare(password, result[0].password); + // remove a password property from JSON + delete result[0].password; + if (match) { const tokendata = { email, ...result[0] }; + // access token const token = getAuthToken(tokendata); + // generate refresh token, + const refreshtoken = getRefreshToken(); + // insert refresh token into DB for that user + await keepRefreshToken(refreshtoken, tokendata.authoruid); + // set expiry of refresh token - forever, need refreshtoken rotation implementation for more security + const refreshtokenexpiry = REFRESH_TOKEN_EXPIRY; + + // set refresh toekn in cookie + const {res} = context; + res.cookie('refreshtoken', refreshtoken, { + maxAge: refreshtokenexpiry, + httpOnly: true, + sameSite: 'Strict', + }); + return { - exist: true, author: { ...author, authoruid: author && author.authoruid }, token, isemailverified + exist: true, + author: { ...author, authoruid: author && author.authoruid }, + token, + isemailverified } } else { diff --git a/src/resolvers/experiences.js b/src/resolvers/experiences.js index df80ae4..be922bf 100644 --- a/src/resolvers/experiences.js +++ b/src/resolvers/experiences.js @@ -6,7 +6,7 @@ import { getSlug, getSlugKey } from '../utils/experiences'; const createARowWithSlugKey = async (authoruid) => { if (!authoruid) { - throw Error('Authouid is required'); + throw Error('Authoruid is required'); } const slugkey = getSlugKey(); diff --git a/src/schema.js b/src/schema.js index bc257d5..9fbefad 100644 --- a/src/schema.js +++ b/src/schema.js @@ -4,7 +4,8 @@ import { exampleupdate, examplequery, saveExperience, getExperiences, getAnExperienceForRead, getAnExperienceForEdit, saveTitle, publishExperience, saveNPublishExperience, deleteAnExperience, - getAuthor, buttonPressRegister, signupAuthor, signinAuthor, updateAuthor, verifyMe, resendVerificationLink + getAuthor, buttonPressRegister, signupAuthor, signinAuthor, updateAuthor, refreshUserToken, + verifyMe, resendVerificationLink } from './resolvers'; import forgotPassword from './resolvers/forgotpassword'; @@ -33,7 +34,7 @@ const Mutation = gql` saveNPublishExperience(input: SaveNPublishExperienceInput): PublishExperienceResponse deleteAnExperience(input: DeleteExperienceInput): DeleteExperienceResponse saveTitle(input: SaveTitleInput): SaveTitleResponse - + refreshUserToken(uid: String!): RefreshUserTokenResponse verifyEmail(input: VerifyEmailInput): VerifyEmailResponse resendVerificationLink(email: String!): ResendVerificaionLinkResponse signupAuthor(input: SignupAuthorInput): SignAuthorResponse @@ -64,6 +65,7 @@ const resolvers = { deleteAnExperience, saveTitle, buttonPressRegister, + refreshUserToken, signupAuthor, updateAuthor, forgotPassword, diff --git a/src/types/authors.js b/src/types/authors.js index dde2f72..cb63689 100644 --- a/src/types/authors.js +++ b/src/types/authors.js @@ -62,6 +62,10 @@ const Authors = gql` type ResendVerificaionLinkResponse { resendsuccess: Boolean } + + type RefreshUserTokenResponse { + token: String + } `; const AuthorsInput = gql` diff --git a/src/utils/getauthtoken.js b/src/utils/getauthtoken.js index 3c0a788..6b2f7d1 100644 --- a/src/utils/getauthtoken.js +++ b/src/utils/getauthtoken.js @@ -1,15 +1,19 @@ import jwt from 'jsonwebtoken'; +import { ACCESS_TOKEN_EXPIRY } from '../config/constants'; -export default (tokendata) => { +const getAccessToken = (tokendata) => { if (!tokendata) { throw Error('Token data missing.'); } - const expiresIn = 60 * 60 * 24 * 7; // 7 days + const expiresIn = ACCESS_TOKEN_EXPIRY; + try { return jwt.sign(tokendata, process.env.JWT_SECRET, { expiresIn }); } catch (err) { console.log('Erro generating token', err); } -}; \ No newline at end of file +}; + +export default getAccessToken; \ No newline at end of file diff --git a/src/utils/getrefreshtoken.js b/src/utils/getrefreshtoken.js new file mode 100644 index 0000000..da6aeed --- /dev/null +++ b/src/utils/getrefreshtoken.js @@ -0,0 +1,13 @@ +const getRefreshToken = () => { + const length = 36; + const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + + let result = ''; + for (var i = length; i > 0; --i) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + + return result; +} + +export default getRefreshToken; \ No newline at end of file