Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simple JWT Auth module #25

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,23 @@
"dependencies": {
"@nestjs/common": "^6.7.2",
"@nestjs/core": "^6.7.2",
"@nestjs/config": "^0.4.1",
"@nestjs/graphql": "^6.5.3",
"@nestjs/platform-express": "^6.7.2",
"@nestjs/jwt": "^7.0.0",
"@nestjs/passport": "^7.0.0",
"@nestjs/platform-express": "^7.0.2",
"@nestjs/platform-fastify": "^6.10.13",
"@nestjs/typeorm": "^6.2.0",
"apollo-server-fastify": "^2.9.15",
"class-validator": "^0.11.0",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"graphql": "^14.5.8",
"graphql-tools": "^4.0.6",
"helmet": "^3.21.2",
"mongodb": "^3.4.1",
"bcrypt": "^4.0.1",
"joi": "^14.3.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.0",
"rxjs": "^6.5.3",
Expand All @@ -54,7 +61,10 @@
"@types/express": "^4.17.1",
"@types/jest": "^24.0.18",
"@types/node": "^12.7.5",
"@types/joi": "^14.3.4",
"@types/supertest": "^2.0.8",
"@types/passport-jwt": "^3.0.3",
"@types/bcrypt": "^3.0.0",
"apollo-server-express": "^2.9.15",
"easygraphql-tester": "^5.1.6",
"faker": "^4.1.0",
Expand Down
5 changes: 5 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { AuthModule } from './auth/auth.module';
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { DateScalar } from './common/scalars/date.scalar';
import { EmailScalar } from './common/scalars/email.scalar';
import { ConfigModule } from './config/config.module';

@Module({
imports: [
TypeOrmModule.forRoot(),
GraphQLModule.forRoot({
autoSchemaFile: 'src/schema.gql',
debug: process.env.NODE_ENV === 'development',
context: ({ req }) => ({ req }),
}),
UsersModule,
ConfigModule,
AuthModule,
],
providers: [DateScalar, EmailScalar],
})
Expand Down
35 changes: 35 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ConfigModule } from '../config/config.module';
import { ConfigService } from '../config/config.service';
import { Module, forwardRef } from '@nestjs/common';
import { AuthService } from './auth.service';
import { PassportModule } from '@nestjs/passport';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AuthResolver } from './auth.resolver';

@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt', session: false }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const options: JwtModuleOptions = {
secret: configService.jwtSecret,
};
if (configService.jwtExpiresIn) {
options.signOptions = {
expiresIn: configService.jwtExpiresIn,
};
}
return options;
},
inject: [ConfigService],
}),
forwardRef(() => UsersModule),
ConfigModule,
],
providers: [AuthService, AuthResolver, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
38 changes: 38 additions & 0 deletions src/auth/auth.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Resolver, Args, Query, Context } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import LoginUserInput from './dto/login-user-input';
import LoginResult from './dto/login-result';
import { AuthService } from './auth.service';
import { AuthenticationError } from 'apollo-server-core';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { UserEntity } from '../users/entities/user.entity';

@Resolver('Auth')
export class AuthResolver {
constructor(private authService: AuthService) {}

@Query(returns => LoginResult)
async login(@Args('user') userInput: LoginUserInput): Promise<LoginResult> {
const result = await this.authService.validateUserByPassword(userInput);
if (result) {
return result;
}

throw new AuthenticationError('Could not log-in with the provided credentials');
}

// There is no username guard here because if the person has the token, they can be any user
@Query(returns => String)
@UseGuards(JwtAuthGuard)
async refreshToken(@Context('req') request: any): Promise<string> {
const user: UserEntity = request.user;
if (!user) {
throw new AuthenticationError('Could not log-in with the provided credentials');
}
const result = await this.authService.createJwt(user);
if (result) {
return result.token;
}
throw new AuthenticationError('Could not log-in with the provided credentials');
}
}
117 changes: 117 additions & 0 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Injectable, forwardRef, Inject } from '@nestjs/common';
import { ConfigService } from '../config/config.service';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import { JwtPayload } from './interfaces/jwt-payload.interface';
import LoginUserInput from './dto/login-user-input';
import LoginResult from './dto/login-result';
import { UserEntity as UserDocument, UserEntity } from '../users/entities/user.entity';

@Injectable()
export class AuthService {
constructor(
@Inject(forwardRef(() => UsersService))
private usersService: UsersService,
private jwtService: JwtService,
private configService: ConfigService,
) {}

/**
* Checks if a user's password is valid
*
* @param {LoginUserInput} loginAttempt Include username or email. If both are provided only
* username will be used. Password must be provided.
* @returns {(Promise<LoginResult | undefined>)} returns the User and token if successful, undefined if not
* @memberof AuthService
*/
async validateUserByPassword(loginAttempt: LoginUserInput): Promise<LoginResult | undefined> {
// This will be used for the initial login
let userToAttempt: UserDocument | undefined;
if (loginAttempt.email) {
userToAttempt = await this.usersService.findOneByEmail(loginAttempt.email);
}

// If the user is not enabled, disable log in - the token wouldn't work anyways
if (userToAttempt && userToAttempt.active === false) {
userToAttempt = undefined;
}

if (!userToAttempt) {
return undefined;
}

// Check the supplied password against the hash stored for this email address
let isMatch = false;
try {
isMatch = await userToAttempt.checkPassword(loginAttempt.password);
} catch (error) {
return undefined;
}

if (isMatch) {
// If there is a successful match, generate a JWT for the user
const token = this.createJwt(userToAttempt!).token;
const result: LoginResult = {
user: userToAttempt!,
token,
};

userToAttempt.updatedAt = new Date();
this.usersService.updateUser(userToAttempt);

return result;
}

return undefined;
}

/**
* Verifies that the JWT payload associated with a JWT is valid by making sure the user exists and is enabled
*
* @param {JwtPayload} payload
* @returns {(Promise<UserDocument | undefined>)} returns undefined if there is no user or the account is not enabled
* @memberof AuthService
*/
async validateJwtPayload(payload: JwtPayload): Promise<UserDocument | undefined> {
// This will be used when the user has already logged in and has a JWT
const user = await this.usersService.findOneByUsername(payload.name);

// Ensure the user exists and their account isn't disabled
if (user) {
user.updatedAt = new Date();
return this.usersService.updateUser(user);
}

return undefined;
}

/**
* Creates a JwtPayload for the given User
*
* @param {User} user
* @returns {{ data: JwtPayload; token: string }} The data contains the email, username, and expiration of the
* token depending on the environment variable. Expiration could be undefined if there is none set. token is the
* token created by signing the data.
* @memberof AuthService
*/
createJwt(user: UserEntity): { data: JwtPayload; token: string } {
const expiresIn = this.configService.jwtExpiresIn;
let expiration: Date | undefined;
if (expiresIn) {
expiration = new Date();
expiration.setTime(expiration.getTime() + expiresIn * 1000);
}
const data: JwtPayload = {
email: user.email,
name: user.name,
expiration,
};

const jwt = this.jwtService.sign(data);

return {
data,
token: jwt,
};
}
}
11 changes: 11 additions & 0 deletions src/auth/dto/login-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Field, ObjectType, Int } from 'type-graphql';
import { UserEntity as User } from '../../users/entities/user.entity';

@ObjectType('LoginResult')
export default class LoginResult {
@Field(type => User)
user: User;

@Field(type => String)
token: string;
}
13 changes: 13 additions & 0 deletions src/auth/dto/login-user-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { IsOptional, Length, MinLength } from 'class-validator';
import { Field, InputType } from 'type-graphql';
import { EmailScalar as Email } from '../../common/scalars/email.scalar';

@InputType('LoginUserInput')
export default class LoginUserInput {
@Field()
password: string;

@Field(type => Email)
@Length(30, 500)
email: string;
}
25 changes: 25 additions & 0 deletions src/auth/guards/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { GqlExecutionContext } from '@nestjs/graphql';
import { UserEntity as User } from '../../users/entities/user.entity';
import { UsersService } from '../../users/users.service';
import { AuthenticationError } from 'apollo-server-core';

// Check if username in field for query matches authenticated user's username
// or if the user is admin
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private usersService: UsersService) {}

canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
if (request.user) {
const user = request.user as User;
if (this.usersService.isAdmin(user.permissions)) {
return true;
}
}
throw new AuthenticationError('Could not authenticate with token or user does not have permissions');
}
}
22 changes: 22 additions & 0 deletions src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthenticationError } from 'apollo-server-core';

@Injectable()
// In order to use AuthGuard together with GraphQL, you have to extend
// the built-in AuthGuard class and override getRequest() method.
export class JwtAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
return request;
}

handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new AuthenticationError('Could not authenticate with token');
}
return user;
}
}
5 changes: 5 additions & 0 deletions src/auth/interfaces/jwt-payload.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface JwtPayload {
email: string;
name: string;
expiration?: Date;
}
29 changes: 29 additions & 0 deletions src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '../../config/config.service';
import { AuthService } from '../auth.service';
import { PassportStrategy } from '@nestjs/passport';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { AuthenticationError } from 'apollo-server-core';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService, configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.jwtSecret,
});
}

// Documentation for this here: https://www.npmjs.com/package/passport-jwt
async validate(payload: JwtPayload) {
// This is called to validate the user in the token exists
const user = await this.authService.validateJwtPayload(payload);

if (!user) {
throw new AuthenticationError('Could not log-in with the provided credentials');
}

return user;
}
}
13 changes: 13 additions & 0 deletions src/config/config.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({
providers: [
{
provide: ConfigService,
useValue: new ConfigService(`.env`),
},
],
exports: [ConfigService],
})
export class ConfigModule {}
Loading