Nest JS Building Auth Service with JWT Tokens Part-3
Blog | Name | Link |
---|---|---|
Part-1 | Nest JS Building REST APIs using Mongo DB Part-1 | https://tkssharma.com/building-rest-apis-with-nestjs-and-mongodb-part-1 |
Part-2 | Nest JS APIs with mongoose Mongo DB Database Part-2 | https://tkssharma.com/building-rest-apis-with-nestjs-and-mongodb-part-2 |
In this post, I will demonstrate how to kickstart a simple RESTful APIs with NestJS from a newbie's viewpoint.
Github Link https://github.com/tkssharma/blogs/tree/master/nestjs-rest-apis-docs
What is NestJS?
As described in the Nestjs website, Nestjs is a progressive Node.js framework for building efficient, reliable and scalable server-side applications.
Nestjs combines the best programming practice and the cutting-edge techniques from the NodeJS communities.
- A lot of NestJS concepts are heavily inspired by the effort of the popular frameworks in the world, esp. Angular .
- Nestjs hides the complexities of web programming in NodeJS, it provides a common abstraction of the web request handling, you are free to choose Expressjs or Fastify as the background engine.
- Nestjs provides a lot of third party project integrations, from database operations, such as Mongoose, TypeORM, etc. to Message Brokers, such as Redis, RabbitMQ, etc.
If you are new to Nestjs like me but has some experience of Angular , TypeDI or Spring WebMVC, bootstraping a Nestjs project is really a piece of cake.
Make sure you have installed the latest Nodejs.
npm i -g @nestjs/cli
When it is finished, there is a nest
command available in the Path
. The usage of nest
is similar with ng
(Angular CLI), type nest --help
in the terminal to list help for all commands.
❯ nest --help
Usage: nest <command> [options]
Options:
-v, --version Output the current version.
-h, --help Output usage information.
Commands:
new|n [options] [name] Generate Nest application.
build [options] [app] Build Nest application.
start [options] [app] Run Nest application.
info|i Display Nest project details.
update|u [options] Update Nest dependencies.
add [options] <library> Adds support for an external library to your project.
generate|g [options] <schematic> [name] [path] Generate a Nest element.
Available schematics:
┌───────────────┬─────────────┐
│ name │ alias │
│ application │ application │
│ class │ cl │
│ configuration │ config │
│ controller │ co │
│ decorator │ d │
│ filter │ f │
│ gateway │ ga │
│ guard │ gu │
│ interceptor │ in │
│ interface │ interface │
│ middleware │ mi │
│ module │ mo │
│ pipe │ pi │
│ provider │ pr │
│ resolver │ r │
│ service │ s │
│ library │ lib │
│ sub-app │ app │
└───────────────┴─────────────┘
Now generate a Nestjs project via:
nest new nestjs-sample
Open it in your favorite IDEs, such as Intellij WebStorm or VSCode.
Exploring the project files
Expand the project root, you will see the following like tree nodes.
.
├── LICENSE
├── nest-cli.json
├── package.json
├── package-lock.json
├── README.md
├── src
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ └── main.ts
├── test
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
The default structure of this project is very similar with the one generated by Angular CLI.
- src/main.ts is the entry file of this application.
- src/app* is the top level component in a nest application.
- There is an app.module.ts is a Nestjs
Module
which is similar with AngularNgModule
, and used to organize codes in the logic view. - The app.service.ts is an
@Injectable
component, similar with the service in Angular or Spring's Service, it is used for handling business logic. A service is annotated with@Injectable
. - The app.controller.ts is the controller of MVC, to handle incoming request, and responds the handled result back to client. The annotatoin
@Controller()
is similar with Spring MVC's@Controller
. - The app.controller.spec.ts is test file for app.controller.ts. Nestjs uses Jest as testing framework.
- There is an app.module.ts is a Nestjs
- test folder is for storing e2e test files.
Protect your APIs with JWT Token
In the last post, we connected to a Mongo server and used a real database to replace the dummy data storage. In this post, we will explore how to protect your APIs when exposing to a client application.
When we come to the security of a web application, technically it will include:
- Authentication - The application will ask you to provide your principal and then it will identify who are you.
- **Authorization ** - Based on your claims, check if you have permissions to perform some operations.
Passportjs is one of the most popular authentication frameworks on the Expressjs platform. Nestjs has great integration with passportjs with its @nestjs/passportjs
module. We will follow the Authentication chapter of the official guide to add local and jwt strategies to the application we have done the previous posts.
Prerequisites
Install passportjs related dependencies.
$ npm install --save @nestjs/passport passport passport-local @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-local @types/passport-jwt
Before starting the authentication work, let's generate some skeleton codes.
Firstly generate a AuthModule
and AuthService
.
nest g mo auth
nest g s auth
The authentication should work with users in the application.
Similarly, create a standalone UserModule
to handle user queries.
nest g mo user
nest g s user
Ok, let's begin to enrich the AuthModule
.
Implementing Authentication
First of all, let's create some resources for the user model, a Document
and Schema
file.
Create new file under /user folder.
import { SchemaFactory, Schema, Prop } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema()
export class User extends Document {
@Prop({ require: true })
readonly username: string;
@Prop({ require: true })
readonly email: string;
@Prop({ require: true })
readonly password: string;
}
export const UserSchema = SchemaFactory.createForClass(User);
The User
class is to wrap a document in Mongoose, and UserSchema
is to describe User
document.
Register UserSchema
in UserModule
, then you can use Model<User>
to perform some operations on User
document.
@Module({
imports: [MongooseModule.forFeature([{ name: 'users', schema: UserSchema }])],
providers: [//...
],
exports: [//...
],
})
export class UserModule {}
The users here is used as the token to identify different Model
when injecting a Model
. When registering a UserSchema
in mongoose, the name attribute in the above MongooseModule.forFeature
is also the collection name of User
documents.
Add a findByUsername
method in UserService
.
@Injectable()
export class UserService {
constructor(@InjectModel('users') private userModel: Model<User>) {}
findByUsername(username: string): Observable<User | undefined> {
return from(this.userModel.findOne({ username }).exec());
}
}
In the @Module
declaration of the UserModule
, register UserService
in providers
, and do not forget to add it into exports
, thus other modules can use this service when importing UserModule
.
//...other imports
import { UserService } from './user.service';
@Module({
providers: [UserService],
exports:[UserService]//exposing users to other modules...
})
export class UserModule {}
Create a test case to test the findByUsername
method.
describe('UserService', () => {
let service: UserService;
let model: Model<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getModelToken('users'),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
model = module.get<Model<User>>(getModelToken('users'));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('findByUsername should return user', async () => {
jest
.spyOn(model, 'findOne')
.mockImplementation((conditions: any, projection: any, options: any) => {
return {
exec: jest.fn().mockResolvedValue({
username: 'hantsy',
email: 'hantsy@example.com',
} as User),
} as any;
});
const foundUser = await service.findByUsername('hantsy').toPromise();
expect(foundUser).toEqual({
username: 'hantsy',
email: 'hantsy@example.com',
});
expect(model.findOne).lastCalledWith({username: 'hantsy'});
expect(model.findOne).toBeCalledTimes(1);
});
});
UserService
depends on a Model<User>
, use a provider to mock it by jest mocking feature. Using jest.spyOn
method, you can stub the details of a methods, and watch of the calling of this method.
Let's move to AuthModule
.
With @nestjs/passpart
, it is simple to set up your passport strategy by extending PassportStrategy
, we will create two passport strategies here.
LocalStrategy
to handle authentication by username and password fields from request.JwtStrategy
to handle authentication by given JWT token header.
Simplify , generate two files by nest command line.
nest g class auth/local.strategy.ts --flat
nest g class auth/jwt.strategy.ts --flat
Firstly, let's implement the LocalStrategy
.
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
usernameField: 'username',
passwordField: 'password',
});
}
validate(username: string, password: string): Observable<any> {
return this.authService
.validateUser(username, password)
.pipe(throwIfEmpty(() => new UnauthorizedException()));
}
}
In the constructor, use super
to provide the essential options of the strategy you are using. For the local strategy, it requires username and password fields.
And the validate method is used to validate the authentication info against given info, here it is the username and password provided from request.
More details about the configuration options and validation of local strategy, check passport-local project.
In AuthService
, add a method validateUser
.
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
validateUser(username: string, pass: string): Observable<any> {
return this.userService.findByUsername(username).pipe(
map(user => {
if (user && user.password === pass) {
const { password, ...result } = user;
return result;
}
return null;
}),
);
}
}
In the real application, we could use a crypto util to hash and compare the input password. We will discuss it in the further post.
It invokes findByUsername
in UserService
from UserModule
. Imports UserModule
in the declaration of AuthModule
.
@Module({
imports: [
UserModule,
...]
...
})
export class AuthModule {}
Let's create a method in AppController
to implement the authentication by given username and password fields.
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
@UseGuards(LocalAuthGuard)
@Post('auth/login')
login(@Req() req: Request): Observable<any> {
return this.authService.login(req.user);
}
}
It simply calls another method login
in AuthService
.
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
//...
login(user: Partial<User>): Observable<any> {
const payload = {
sub: user.username,
email: user.email,
roles: user.roles,
};
return from(this.jwtService.signAsync(payload)).pipe(
map(access_token => {
access_token;
}),
);
}
}
The login
method is responsible for generating a JWT based access token based on the authenticated principal.
The URI path auth/login
use a LocalAuthGuard
to protect it.
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
Let's summarize how local strategy works.
- When a user hits auth/login with
username
andpassword
,LocalAuthGuard
will be applied. LocalAuthGuard
will triggerLocalStrategy
, and invokes itsvalidate
method, and store the result back torequest.user
.- Back the controller, read user principal from
request
, generate a JWT token and send it back to the client.
After logging in, the access token
can be extracted and put into the HTTP header in the new request to access the protected resources.
Let's have a look at how JWT strategy works.
Firstly implement the JwtStrategy
.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
validate(payload: any) :any{
return { email: payload.email, sub: payload.username };
}
}
In the constructor, there are several options configured.
The jwtFromRequest
specifies the approach to extract token, it can be from HTTP cookie or request header Authorization
.
If ignoreExpiration
is false, when decoding the JWT token, it will check expiration date.
The secretOrKey
is used to sign the JWT token or decode token.
In the validate
method, the payload is the content of decoded JWT claims. You can add custom validation based on the claims.
More about the configuration options and verify method, check passport-jwt project.
In the declaration of AuthModule
, imports JwtModule
, it accept a register method to add initial options for signing the JWT token.
@Module({
imports: [
// ...
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [//...,
LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
Similarly create a JwtAuthGuard
, and register it in the providers in AuthModule
.
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt'){}
Create a method to read profile of the current user.
@Controller()
export class AppController {
constructor(private authService: AuthService) {}
//...
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Req() req: Request): any {
return req.user;
}
}
Let's review the workflow of the JWT strategy.
- Given a JWT token
XXX
, access /profile with headerAuthorization:Bearer XXX
. JwtAuthGuard
will triggerJwtStrategy
, and callsvalidate
method, and store the result back torequest.user
.- In the
getProfile
method, send therequest.user
to client.
If you want to set a default strategy, change PassportModule
in the declaration of AuthModule
to the following.
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
//...
})
export class AuthModule {}
There are several application lifecycle hooks provided in Nestjs at runtime. In your codes you can observe these lifecycle events and perform some specific tasks for your application.
For example, create a data initializer for Post
to insert sample data.
@Injectable()
export class UserDataInitializerService
implements OnModuleInit, OnModuleDestroy {
constructor(@InjectModel('users') private userModel: Model<User>) {}
onModuleInit(): void {
console.log('(UserModule) is initialized...');
this.userModel
.create({
username: 'hantsy',
password: 'password',
email: 'hantsy@example.com',
})
.then(data => console.log(data));
}
onModuleDestroy(): void {
console.log('(UserModule) is being destroyed...');
this.userModel
.deleteMany({})
.then(del => console.log(`deleted ${del.deletedCount} rows`));
}
}
More info about the lifecycle hooks, check the Lifecycle events chapter of the official docs.
Run the application
Open your terminal, run the application by executing the following command.
npm run start
Login using the username/password pair.
>curl http://localhost:3000/auth/login -d "{\"username\":\"hantsy\", \"password\":\"password\"}" -H "Content-Type:application/json"
>{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZjJkMGU0ODZhOTZiZTEyMDBmZWZjZWMiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTk2Nzg4NDg5LCJleHAiOjE1OTY3OTIwODl9.4oYpKTikoTfeeaUBoEFr9d1LPcN1pYqHjWXRuZXOfek"}
Try to access the /profile endpoint using this access_token.
>curl http://localhost:3000/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cG4iOiJoYW50c3kiLCJzdWIiOiI1ZjJkMGU0ODZhOTZiZTEyMDBmZWZjZWMiLCJlbWFpbCI6ImhhbnRzeUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlVTRVIiXSwiaWF0IjoxNTk2Nzg4NDg5LCJleHAiOjE1OTY3OTIwODl9.4oYpKTikoTfeeaUBoEFr9d1LPcN1pYqHjWXRuZXOfek"
{"username":"hantsy","email":"hantsy@example.com","id":"5f2d0e486a96be1200fefcec","roles":["USER"]}
Grab the source codes from my github, switch to branch feat/auth.
Comments