Skip to content

Commit

Permalink
refresh token implementation, no token rotation yet, store in secure …
Browse files Browse the repository at this point in the history
…cookie
  • Loading branch information
mauryakrishna committed Dec 6, 2020
1 parent 589ba69 commit 08105bd
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 18 deletions.
22 changes: 15 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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!
Expand Down
2 changes: 2 additions & 0 deletions migrations/sqls/20200517131811-authors-up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down
8 changes: 6 additions & 2 deletions src/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
79 changes: 76 additions & 3 deletions src/resolvers/authors.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/resolvers/experiences.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions src/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -64,6 +65,7 @@ const resolvers = {
deleteAnExperience,
saveTitle,
buttonPressRegister,
refreshUserToken,
signupAuthor,
updateAuthor,
forgotPassword,
Expand Down
4 changes: 4 additions & 0 deletions src/types/authors.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ const Authors = gql`
type ResendVerificaionLinkResponse {
resendsuccess: Boolean
}
type RefreshUserTokenResponse {
token: String
}
`;

const AuthorsInput = gql`
Expand Down
10 changes: 7 additions & 3 deletions src/utils/getauthtoken.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
};

export default getAccessToken;
13 changes: 13 additions & 0 deletions src/utils/getrefreshtoken.js
Original file line number Diff line number Diff line change
@@ -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;

0 comments on commit 08105bd

Please sign in to comment.