Skip to content

Commit

Permalink
feat: add user and token services
Browse files Browse the repository at this point in the history
  • Loading branch information
saisilinus committed Dec 15, 2021
1 parent 02ffcf8 commit a92dd01
Show file tree
Hide file tree
Showing 13 changed files with 343 additions and 55 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"plugins": ["jest", "security", "prettier", "@typescript-eslint"],
"parserOptions": {
"ecmaVersion": 2018,
"project": "./src/tsconfig.json"
"project": ["./tsconfig.json", "./src/tsconfig.json"]
},
"rules": {
"no-console": "error",
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module.exports = {
export default {
testEnvironment: 'node',
testEnvironmentOptions: {
NODE_ENV: 'test',
Expand Down
27 changes: 27 additions & 0 deletions src/Tokens/token.interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ObjectId } from 'mongoose';
import { JwtPayload } from 'jsonwebtoken';

export interface IToken {
token: string;
user: ObjectId;
type: string;
expires: Date;
blacklisted: boolean;
}

export interface IPayload extends JwtPayload {
sub: string;
iat: number;
exp: number;
type: string;
}

export interface TokenPayload {
token: string;
expires: Date;
}

export interface AccessAndRefreshTokens {
access: TokenPayload;
refresh: TokenPayload;
}
15 changes: 4 additions & 11 deletions src/Tokens/tokens.model.ts → src/Tokens/token.model.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import mongoose, { ObjectId, Schema, model } from 'mongoose';
import ITokens from './tokens.types';
import mongoose, { Schema, model } from 'mongoose';
import tokenTypes from './token.types';
import toJSON from '../plugins/toJSON';

interface IToken {
token: string;
user: ObjectId;
type: string;
expires: Date;
blacklisted: boolean;
}
import { IToken } from './token.interfaces';

const tokenSchema = new Schema<IToken>(
{
Expand All @@ -24,7 +17,7 @@ const tokenSchema = new Schema<IToken>(
},
type: {
type: String,
enum: [ITokens.REFRESH, ITokens.RESET_PASSWORD, ITokens.VERIFY_EMAIL],
enum: [tokenTypes.REFRESH, tokenTypes.RESET_PASSWORD, tokenTypes.VERIFY_EMAIL],
required: true,
},
expires: {
Expand Down
136 changes: 136 additions & 0 deletions src/Tokens/token.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import jwt from 'jsonwebtoken';
import moment, { Moment } from 'moment';
import mongoose, { ObjectId } from 'mongoose';
import httpStatus from 'http-status';
import config from '../config/config';
import Token from './token.model';
import ApiError from '../utils/ApiError';
import tokenTypes from './token.types';
import { AccessAndRefreshTokens, IToken } from './token.interfaces';
import { ToJSONUser } from '../Users/user.interfaces';
import { getUserByEmail } from '../Users/user.service';

/**
* Generate token
* @param {ObjectId} userId
* @param {Moment} expires
* @param {string} type
* @param {string} [secret]
* @returns {string}
*/
export const generateToken = (
userId: ObjectId,
expires: Moment,
type: string,
secret: string = config.jwt.secret
): string => {
const payload = {
sub: userId,
iat: moment().unix(),
exp: expires.unix(),
type,
};
return jwt.sign(payload, secret);
};

/**
* Save a token
* @param {string} token
* @param {ObjectId} userId
* @param {Moment} expires
* @param {string} type
* @param {boolean} [blacklisted]
* @returns {Promise<IToken>}
*/
export const saveToken = async (
token: string,
userId: ObjectId,
expires: Moment,
type: string,
blacklisted: boolean = false
): Promise<IToken> => {
const tokenDoc = await Token.create({
token,
user: userId,
expires: expires.toDate(),
type,
blacklisted,
});
return tokenDoc;
};

/**
* Verify token and return token doc (or throw an error if it is not valid)
* @param {string} token
* @param {string} type
* @returns {Promise<IToken>}
*/
export const verifyToken = async (token: string, type: string): Promise<IToken> => {
const payload = jwt.verify(token, config.jwt.secret);
let tokenDoc;
if (typeof payload.sub === 'string') {
tokenDoc = await Token.findOne({
token,
type,
user: new mongoose.Schema.Types.ObjectId(payload.sub),
blacklisted: false,
});
}
if (!tokenDoc) {
throw new Error('Token not found');
}
return tokenDoc;
};

/**
* Generate auth tokens
* @param {ToJSONUser} user
* @returns {Promise<AccessAndRefreshTokens>}
*/
export const generateAuthTokens = async (user: ToJSONUser): Promise<AccessAndRefreshTokens> => {
const accessTokenExpires = moment().add(config.jwt.accessExpirationMinutes, 'minutes');
const accessToken = generateToken(user.id, accessTokenExpires, tokenTypes.ACCESS);

const refreshTokenExpires = moment().add(config.jwt.refreshExpirationDays, 'days');
const refreshToken = generateToken(user.id, refreshTokenExpires, tokenTypes.REFRESH);
await saveToken(refreshToken, user.id, refreshTokenExpires, tokenTypes.REFRESH);

return {
access: {
token: accessToken,
expires: accessTokenExpires.toDate(),
},
refresh: {
token: refreshToken,
expires: refreshTokenExpires.toDate(),
},
};
};

/**
* Generate reset password token
* @param {string} email
* @returns {Promise<string>}
*/
export const generateResetPasswordToken = async (email: string): Promise<string> => {
const user = await getUserByEmail(email);
if (!user) {
throw new ApiError(httpStatus.NOT_FOUND, 'No users found with this email');
}
const expires = moment().add(config.jwt.resetPasswordExpirationMinutes, 'minutes');
const resetPasswordToken = generateToken(user.id, expires, tokenTypes.RESET_PASSWORD);
await saveToken(resetPasswordToken, user.id, expires, tokenTypes.RESET_PASSWORD);
return resetPasswordToken;
};

/**
* Generate verify email token
* @param {ToJSONUser} user
* @returns {Promise<string>}
*/
export const generateVerifyEmailToken = async (user: ToJSONUser): Promise<string> => {
const expires = moment().add(config.jwt.verifyEmailExpirationMinutes, 'minutes');
const verifyEmailToken = generateToken(user.id, expires, tokenTypes.VERIFY_EMAIL);
await saveToken(verifyEmailToken, user.id, expires, tokenTypes.VERIFY_EMAIL);
return verifyEmailToken;
};
File renamed without changes.
45 changes: 45 additions & 0 deletions src/Users/user.interfaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Model, ObjectId } from 'mongoose';
import { QueryResult } from '../plugins/paginate';

export interface IUser {
name: string;
email: string;
password: string;
role?: string;
isEmailVerified?: boolean;
}

export interface ToJSONUser {
name: string;
email: string;
password: string;
role: string;
isEmailVerified: boolean;
id: ObjectId;
}

export interface IUserStatics extends Model<IUser> {
isEmailTaken(email: string, excludeUserId?: ObjectId): Promise<boolean>;
isPasswordMatch(password: string): Promise<boolean>;
paginate(filter: Record<string, any>, options: Record<string, any>): Promise<QueryResult>;
toJSON(): void;
}

export interface UpdateUserBody {
name?: string;
email?: string;
password?: string;
}

export interface NewRegisteredUser {
name: string;
email: string;
password: string;
}

export interface NewCreatedUser {
name: string;
email: string;
password: string;
role: string;
}
32 changes: 10 additions & 22 deletions src/Users/users.model.ts → src/Users/user.model.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
import { Schema, model, Model } from 'mongoose';
import { Schema, model, ObjectId } from 'mongoose';
import validator from 'validator';
import bcrypt from 'bcryptjs';
import toJSON from '../plugins/toJSON';
import paginate from '../plugins/paginate';
import { roles } from '../config/roles';
import { IUser, IUserStatics } from './user.interfaces';

interface IUser {
name: string;
email: string;
password: string;
role?: string;
isEmailVerified?: boolean;
}

interface IStatics extends Model<IUser> {
isEmailTaken(): Promise<boolean>;
isPasswordMatch(): Promise<boolean>;
}

const userSchema = new Schema<IUser, IStatics>(
const userSchema = new Schema<IUser, IUserStatics>(
{
name: {
type: String,
Expand All @@ -31,7 +19,7 @@ const userSchema = new Schema<IUser, IStatics>(
unique: true,
trim: true,
lowercase: true,
validate(value) {
validate(value: string) {
if (!validator.isEmail(value)) {
throw new Error('Invalid email');
}
Expand All @@ -42,7 +30,7 @@ const userSchema = new Schema<IUser, IStatics>(
required: true,
trim: true,
minlength: 8,
validate(value) {
validate(value: string) {
if (!value.match(/\d/) || !value.match(/[a-zA-Z]/)) {
throw new Error('Password must contain at least one letter and one number');
}
Expand Down Expand Up @@ -74,20 +62,20 @@ userSchema.plugin(paginate);
* @param {ObjectId} [excludeUserId] - The id of the user to be excluded
* @returns {Promise<boolean>}
*/
userSchema.statics.isEmailTaken = async function (email, excludeUserId) {
userSchema.static('isEmailTaken', async function (email: string, excludeUserId: ObjectId): Promise<boolean> {
const user = await this.findOne({ email, _id: { $ne: excludeUserId } });
return !!user;
};
});

/**
* Check if password matches the user's password
* @param {string} password
* @returns {Promise<boolean>}
*/
userSchema.methods.isPasswordMatch = async function (password) {
userSchema.method('isPasswordMatch', async function (password: string): Promise<boolean> {
const user = this;
return bcrypt.compare(password, user.password);
};
});

userSchema.pre('save', async function (next) {
const user = this;
Expand All @@ -97,6 +85,6 @@ userSchema.pre('save', async function (next) {
next();
});

const User = model<IUser>('User', userSchema);
const User = model<IUser, IUserStatics>('User', userSchema);

export default User;

0 comments on commit a92dd01

Please sign in to comment.