Skip to content

Commit

Permalink
Feat/local user login (#10)
Browse files Browse the repository at this point in the history
* feat: setup local passport

* feat: apply local passport & setup loccal strategy

* feat: apply local auth guard & user login controller

* feat: setup passport-jwt

* feat: setup jwt env value & check whether jwt env is set during initialization

* refactor: put insensitive user information into jwt

* refactor: set config as global variables

* feat: setup jwt settings

* feat: apply jwt when login return jwt token

* feat: setting default jwt secert for jest test

* feat: setup jest test need modules

* feat: add swagger description

* feat: make local auth guard test

* feat: make localStrategy test

* feat: make jwt auth guard test

* feat: make auth service test

* refactor: reduce unnecessary code

* refactor: reduce unnecessary return information

* feat: make auth controller test login route

* refactor: revise the filename definition which is use for user login unauthorized error

* refactor: set mock environment variable

* refactor: jest test use mock environment variable

* feat: add JWT_SECRET .env.example

* refactor: revised that Response instead of Respose

* refactor: revised grammar questions about words

* refactor: revised the error message of IsNotEmpty in login-user.dto.ts

* fix: restore proper authentication

* refactor: revised recommend in auth.service.ts

* fix: delete email field in jwt payload

* fix: delete default secret

* fix: use forbidden when account information is wrong

* refactor: make fake access token as a variable

* feat: add unit test for non-existent account and wrong password

* refactor: add type devDependencies about passport

* fix: fix authController request type error

* refactor: change jwt* to jwtAccess*

* test: revised to match this functional description in localAuthGuard

* refactor: remove meanless comment

* refactor: delete jwtAccessConfigJest use by jwtAccessConfig

* refactor: check all errors first and only respond when everything is correct

* refactor: revised auth login api response description

* refactor: revised spelling mistakes

* test: change to httpException because the test will always passed due to the assertion

* feat: add refresh token secret setting

* feat: add auth refresh api

* test: delete jwtAccessConfig

* test: use time mock to achieve time acceleration

* test: add refresh token unit test

* feat: add validation field information in LocalAuthGuard

* test: add validation field information in LocalAuthGuard

* test: add a user found in UserService

* test: add a script to test a file

* test: add unit test coverage inspection scope

* feat: enable BearerAuth functionality for Swagger

* feat: add Swagger documentation for the refresh token API

* refactor: correct spelling which is BadRequest

* refactor: add swagger securitySchemes

* test: optimize the code according to the suggestions

* refactor: revise the document according to the OpenAPI specifications
  • Loading branch information
a20688392 authored Sep 3, 2023
1 parent 35d1f22 commit 743ce1f
Show file tree
Hide file tree
Showing 38 changed files with 1,251 additions and 200 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ DB_PASSWORD=secret
DB_DATABASE=cophr
DB_TIMEZONE="+08:00"

JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=

APP_SWAGGER_Title="NestJS-Board API"
APP_SWAGGER_Description="This is NestJS-Board API documentation."
APP_SWAGGER_Version="0.0.1"
15 changes: 14 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"test": "jest --verbose",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:one": "jest --testPathPattern",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "npm run build && npx typeorm -d ./dist/config/data-source.js",
Expand All @@ -36,7 +37,9 @@
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
"@nestjs/jwt": "^10.1.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/swagger": "^6.1.4",
"@nestjs/typeorm": "^9.0.1",
Expand All @@ -45,6 +48,9 @@
"class-validator": "^0.14.0",
"dotenv": "^16.0.3",
"mysql2": "^2.3.3",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
Expand All @@ -61,6 +67,9 @@
"@types/express": "^4.17.13",
"@types/jest": "28.1.8",
"@types/node": "^16.0.0",
"@types/passport": "^1.0.12",
"@types/passport-jwt": "^3.0.8",
"@types/passport-local": "^1.0.35",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",
Expand Down Expand Up @@ -95,7 +104,11 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
"**/*.(t|j)s",
"!**/swagger/**",
"!**/database/**",
"!**/main.ts",
"!**/config/**"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
Expand Down
3 changes: 3 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { AuthModule } from "./auth/auth.module";
import appConfig from "./config/app.config";
import { dataSourceOptions } from "./config/data-source";
import { validate } from "./config/env.validation";
import { UserModule } from "./user/user.module";
Expand All @@ -13,6 +14,8 @@ import { UserModule } from "./user/user.module";
controllers: [AppController],
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig],
validate,
}),
TypeOrmModule.forRoot(dataSourceOptions),
Expand Down
119 changes: 92 additions & 27 deletions src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { type HttpException, ConflictException } from "@nestjs/common";
import { ConflictException } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { type TestingModule, Test } from "@nestjs/testing";
import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm";
import { type Request } from "express";
import { dataSourceJest } from "src/config/data-source";
import jestConfig from "src/config/jest.config";
import { UserEntity } from "src/user/entities/user.entity";
import type { CreateUserRespose } from "src/user/resposes/create-user-respose";
import type { CreateUserResponse } from "src/user/responses/create-user-response";
import { UserService } from "src/user/user.service";
import type { Repository } from "typeorm";

import type { CreateUserDto } from "../user/dto/create-user.dto";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { type JwtUser } from "./jwt/jwt.interface";
import { JwtAccessStrategy } from "./jwt/jwt-access.strategy";
import { LocalStrategy } from "./local/local.strategy";
import { type GenerateTokenResponse } from "./responses/generate-token.response";

describe("AuthController", () => {
let authController: AuthController;
Expand All @@ -19,7 +28,14 @@ describe("AuthController", () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
imports: [TypeOrmModule.forRoot(dataSourceJest)],
imports: [
ConfigModule.forRoot({
load: [jestConfig],
}),
PassportModule,
TypeOrmModule.forRoot(dataSourceJest),
JwtModule.register({}),
],
providers: [
AuthService,
UserService,
Expand All @@ -28,6 +44,8 @@ describe("AuthController", () => {
// 使用測試資料庫的 Repository
useValue: UserEntity,
},
LocalStrategy,
JwtAccessStrategy,
],
}).compile();

Expand All @@ -39,43 +57,90 @@ describe("AuthController", () => {
});

describe("create", () => {
const createUserDto: CreateUserDto = {
account: "account",
email: "[email protected]",
name: "displayname",
password: "Password@123",
};
let mockedAuthService: jest.SpyInstance;

beforeEach(async () => {
mockedAuthService = jest.spyOn(authService, "register");
});

it("應該會創建一個使用者,並返回 201 狀態碼", async () => {
const createUserDto: CreateUserDto = {
account: "account",
email: "[email protected]",
name: "displayname",
password: "Password@123",
};
const expectedResponse: CreateUserRespose = {
const expectedResponse: CreateUserResponse = {
message: "創建成功",
statusCode: 201,
};

jest.spyOn(authService, "register").mockResolvedValue(expectedResponse);
mockedAuthService.mockResolvedValue(expectedResponse);

const result = await authController.register(createUserDto);

expect(result).toEqual(expectedResponse);
});

it("應該會發生資料使用者重覆,並返回 409 狀態碼", async () => {
const createUserDto1: CreateUserDto = {
account: "account1",
email: "[email protected]",
name: "displayname",
password: "Password@123",
await authService.register(createUserDto);
await authService.register(createUserDto).catch(error => {
expect(error).toBeInstanceOf(ConflictException);
expect((error as ConflictException).getResponse()).toEqual({
error: "Conflict",
message: ["email 已被註冊。", "account 已被註冊。"],
statusCode: 409,
});
});
});
});

describe("login and refresh", () => {
const request: Request = {
user: {
id: 1,
} as JwtUser,
} as unknown as Request;
const fakeAccessToken = "mocked_access_token";
const fakeRefreshToken = "mocked_refresh_token";
let mockedAuthService: jest.SpyInstance;

beforeEach(async () => {
jest
.spyOn(authService, "generateAccessToken")
.mockReturnValue(Promise.resolve(fakeAccessToken));

jest
.spyOn(authService, "generateRefreshToken")
.mockReturnValue(Promise.resolve(fakeRefreshToken));

mockedAuthService = jest.spyOn(authService, "sign");
});

it("should return access, refresh token and 201 http code when account information is correct.", async () => {
const result = await authController.login(request);

expect(mockedAuthService).toHaveBeenCalledWith(request.user);
const expectedResponse: GenerateTokenResponse = {
accessToken: fakeAccessToken,
refreshToken: fakeRefreshToken,
statusCode: 201,
};

await authService.register(createUserDto1);
await authService
.register(createUserDto1)
.catch((error: HttpException) => {
expect(error).toBeInstanceOf(ConflictException);
expect(error.getResponse()).toEqual({
error: "Conflict",
message: ["email 已被註冊。", "account 已被註冊。"],
statusCode: 409,
});
});
expect(result).toEqual(expectedResponse);
});

it("should return access, refresh token and 201 http code when refresh token is correct.", async () => {
const result = await authController.refresh(request);

expect(mockedAuthService).toHaveBeenCalledWith(request.user);
const expectedResponse: GenerateTokenResponse = {
accessToken: fakeAccessToken,
refreshToken: fakeRefreshToken,
statusCode: 201,
};

expect(result).toEqual(expectedResponse);
});
});

Expand Down
70 changes: 63 additions & 7 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { Body, Controller, Post } from "@nestjs/common";
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConflictResponse,
ApiCreatedResponse,
ApiForbiddenResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { Request } from "express";
import { BadRequestError } from "src/error/bad-request-error";
import { ConflictError } from "src/error/conflict-error";
import { ForbiddenError } from "src/error/forbidden-error";
import { UnauthorizedError } from "src/error/unauthorized-error";
import { CreateUserDto } from "src/user/dto/create-user.dto";
import { CreateUserBadrequestError } from "src/user/exceptions/create-user-badrequest-error.exception";
import { CreateUserConflictError } from "src/user/exceptions/create-user-conflict-error.exception";
import { CreateUserRespose } from "src/user/resposes/create-user-respose";
import { LoginUserDto } from "src/user/dto/login-user.dto";
import { CreateUserResponse } from "src/user/responses/create-user-response";

import { AuthService } from "./auth.service";
import { type JwtUser } from "./jwt/jwt.interface";
import { JwtRefreshGuard } from "./jwt/jwt-refresh.guard";
import { LocalAuthGuard } from "./local/local-auth.guard";
import { GenerateTokenResponse } from "./responses/generate-token.response";

@ApiTags("Auth")
@Controller("auth")
Expand All @@ -25,17 +37,61 @@ export class AuthController {
})
@ApiCreatedResponse({
description: "使用者創建成功",
type: CreateUserRespose,
type: CreateUserResponse,
})
@ApiConflictResponse({
description: "使用者資料重覆",
type: CreateUserConflictError,
type: ConflictError,
})
@ApiBadRequestResponse({
description: "使用者格式不符",
type: CreateUserBadrequestError,
type: BadRequestError,
})
async register(@Body() userDto: CreateUserDto) {
return this.authService.register(userDto);
}

@Post("login")
@UseGuards(LocalAuthGuard)
@ApiOperation({
description:
"Will return access and refresh token when the account information is correct.",
summary: "login with our account",
})
@ApiCreatedResponse({
description: "Success with generated token",
type: GenerateTokenResponse,
})
@ApiForbiddenResponse({
description: "Account information error",
type: ForbiddenError,
})
@ApiBody({ type: LoginUserDto })
async login(@Req() request: Request) {
return this.authService.sign(request.user as JwtUser);
}

@Get("refresh")
@ApiBearerAuth()
@UseGuards(JwtRefreshGuard)
@ApiOperation({
description:
"Will return access and refresh token when the refresh token carried in the request is valid.",
summary: "refresh token",
})
@ApiCreatedResponse({
description: "Success with generated token",
type: GenerateTokenResponse,
})
@ApiUnauthorizedResponse({
description: String(
"Token error \n " +
"When the token is not carried in the request. \n" +
"When the token has expired or become invalid.",
),
type: UnauthorizedError,
})
async refresh(@Req() request: Request) {
return this.login(request);
}
}
19 changes: 17 additions & 2 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserEntity } from "src/user/entities/user.entity";
import { UserModule } from "src/user/user.module";

import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtAccessStrategy } from "./jwt/jwt-access.strategy";
import { JwtRefreshStrategy } from "./jwt/jwt-refresh.strategy";
import { LocalStrategy } from "./local/local.strategy";

@Module({
controllers: [AuthController],
imports: [UserModule, TypeOrmModule.forFeature([UserEntity])],
providers: [AuthService],
imports: [
UserModule,
PassportModule,
TypeOrmModule.forFeature([UserEntity]),
JwtModule.register({}),
],
providers: [
AuthService,
LocalStrategy,
JwtAccessStrategy,
JwtRefreshStrategy,
],
})
export class AuthModule {}
Loading

0 comments on commit 743ce1f

Please sign in to comment.