diff --git a/.env.copy b/.env.copy index 4680df1..cf5b595 100644 --- a/.env.copy +++ b/.env.copy @@ -15,6 +15,10 @@ RMQ_QUEUE_DURABLE=false RABBITMQ_USERNAME=username RABBITMQ_PASSWORD=password +# Oauth +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= + # Health Check Params HEAP_SIZE_CHECK=150 * 1024 * 1024 DISK_THRESHOLD_CHECK=0.5 diff --git a/prisma/migrations/20230830070722_user/migration.sql b/prisma/migrations/20230830070722_user/migration.sql new file mode 100644 index 0000000..ea032fa --- /dev/null +++ b/prisma/migrations/20230830070722_user/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "userStatus" INTEGER NOT NULL DEFAULT 1, + "firstName" TEXT, + "lastName" TEXT, + "phone" TEXT, + "username" TEXT, + "profileImg" TEXT, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e11e542..f219a4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,12 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - email String @unique - name String? + id Int @id @default(autoincrement()) + email String @unique + userStatus Int @default(1) + firstName String? + lastName String? + phone String? + username String? @unique + profileImg String? } diff --git a/src/app.module.ts b/src/app.module.ts index f408ba3..345a947 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { PrismaHealthIndicator } from './health/prisma.health'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { HealthController } from './health/health.controller'; import { PrismaModule } from './prisma/prisma.module'; +import { AuthModule } from './auth/auth.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { PrismaModule } from './prisma/prisma.module'; HttpModule, TerminusModule, PrismaModule, + AuthModule, ], controllers: [AppController, HealthController], providers: [AppService, PrismaService, PrismaHealthIndicator], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..6ec790e --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,127 @@ +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { User } from '@prisma/client'; +import { AuthService } from './auth.service'; +import { + AccessToken, + AuthURL, + ExchangeResponse, + ExchangeToken, + RefreshToken, + RevokeToken, + UserDTO, + UserResponse, +} from './types'; + +@Controller('auth') +@ApiTags('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @ApiTags('auth') + @ApiOperation({ + summary: 'Get authorization url from google', + }) + @ApiResponse({ + status: 200, + description: 'Returns authorization url', + type: AuthURL, + }) + @Get('google') + async getAuthorizationUrl(): Promise { + const options = { + scopes: [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive', // Google Drive scope + 'https://www.googleapis.com/auth/documents', // Google Docs scope + ], + }; + return { + url: this.authService.getAuthorizationUrl(options), + }; + } + + @ApiTags('auth') + @ApiOperation({ + summary: 'Exchange authorization code for access token', + }) + @ApiResponse({ + status: 200, + description: 'Returns access token', + type: AccessToken, + }) + @Post('exchange-code') + async exchangeAuthorizationCode( + @Body() body: ExchangeToken, + ): Promise { + return await this.authService.exchangeAuthorizationCode(body.code); + } + + @ApiTags('auth') + @ApiOperation({ + summary: 'Refresh access token', + }) + @ApiResponse({ + status: 200, + description: 'Returns access token', + type: AccessToken, + }) + @Post('refresh-access-token') + async refreshAccessToken(@Body() body: RefreshToken): Promise { + return this.authService.refreshAccessToken(body.refreshToken); + } + + @ApiTags('auth') + @ApiOperation({ + summary: 'Revoke access token', + }) + @ApiResponse({ + status: 200, + description: 'Returns void', + }) + @Post('revoke-token') + async logout(@Body() body: RevokeToken): Promise { + return this.authService.revokeToken(body.accessToken); + } + + @ApiTags('auth') + @ApiOperation({ + summary: 'Get user details by id', + }) + @ApiResponse({ + status: 200, + description: 'Returns user details', + type: UserResponse, + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + @Get('user/:id') + async getUser(@Param('id') id: string): Promise { + return this.authService.getUserDetails(id); + } + + @ApiTags('auth') + @ApiOperation({ + summary: 'Update user details', + }) + @ApiResponse({ + status: 200, + description: 'Returns user details', + type: UserResponse, + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + @Put('user/:id') + async updateUser( + @Param('id') id: string, + @Body() + user: UserDTO, + ): Promise { + return this.authService.updateUserDetails(id, user); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..2fa1f4b --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [AuthService], + controllers: [AuthController], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..75cd2b5 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,178 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import axios from 'axios'; +import { AuthService } from './auth.service'; +import { AccessToken, ExchangeResponse, UserDTO } from './types'; +import { User } from '@prisma/client'; + +jest.mock('axios'); + +describe('AuthService', () => { + let authService: AuthService; + let prismaService: PrismaService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService, PrismaService], + }).compile(); + + authService = module.get(AuthService); + prismaService = module.get(PrismaService); + }); + + describe('exchangeAuthorizationCode', () => { + it('should exchange authorization code for access token', async () => { + const mockExchangeResponse: ExchangeResponse = { + access_token: 'mock_token', + expires_in: 3600, + refresh_token: 'mock_refresh_token', + }; + + const mockUserinfo = { + data: { + email: 'test@example.com', + picture: 'profile_picture_url', + }, + }; + + // Mock Axios responses + const mockTokenResponse = { + data: { + access_token: 'mock_token', + expires_in: 3600, + refresh_token: 'mock_refresh_token', + }, + }; + const mockUserinfoResponse = mockUserinfo; + + (axios.post as jest.Mock).mockResolvedValueOnce(mockTokenResponse); + (axios.get as jest.Mock).mockResolvedValueOnce(mockUserinfoResponse); + + const result = await authService.exchangeAuthorizationCode('mock_code'); + + expect(result).toEqual(mockExchangeResponse); + // verifying axios calls + expect(axios.post).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + expect.any(URLSearchParams), + expect.any(Object), + ); + expect(axios.get).toHaveBeenCalledWith( + 'https://www.googleapis.com/oauth2/v3/userinfo?alt=json', + expect.any(Object), + ); + }); + }); + + describe('revokeToken', () => { + it('should revoke the token', async () => { + const mockToken = 'mock_token'; + + // Mock Axios response + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200 }); + + await authService.revokeToken(mockToken); + + const expectedParams = new URLSearchParams(); + expectedParams.append('token', mockToken); + + expect(axios.get).toHaveBeenCalledWith( + 'https://accounts.google.com/o/oauth2/revoke', + { + params: expectedParams, + }, + ); + }); + }); + + describe('refreshAccessToken', () => { + it('should refresh access token', async () => { + const mockRefreshToken = 'mock_refresh_token'; + const mockAccessToken: AccessToken = { + token: 'mock_new_access_token', + expiresAt: new Date(Date.now() + 3600 * 1000), + refreshToken: 'mock_refresh_token', + }; + + // Mock Axios response + const mockTokenResponse = { + data: { + access_token: 'mock_new_access_token', + expires_in: 3600, + refresh_token: 'mock_refresh_token', + }, + }; + (axios.post as jest.Mock).mockResolvedValueOnce(mockTokenResponse); + + const result = await authService.refreshAccessToken(mockRefreshToken); + + expect(result).toEqual(mockAccessToken); + expect(axios.post).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + expect.any(URLSearchParams), + expect.any(Object), + ); + }); + }); + + describe('updateUserDetails', () => { + it('should update user details', async () => { + const mockEmail = 'test@gmail.com'; + const mockUserDTO: UserDTO = { + firstName: 'test', + lastName: 'user', + phone: '1234567890', + username: 'testuser', + }; + + const mockUser: User = { + id: 1, + email: 'test@gmail.com', + firstName: 'test', + lastName: 'user', + phone: '1234567890', + username: 'testuser', + userStatus: 1, + profileImg: 'profile_picture_url', + }; + + jest + .spyOn(prismaService.user, 'findUnique') + .mockResolvedValueOnce(mockUser); + jest.spyOn(prismaService.user, 'update').mockResolvedValueOnce(mockUser); + + const result = await authService.updateUserDetails( + mockEmail, + mockUserDTO, + ); + + expect(result).toEqual(mockUser); + }); + }); + + describe('getUserDetails', () => { + it('should get user details', async () => { + const mockEmail = 'test@gmail.com'; + const mockUser: User = { + id: 1, + email: 'test@gmail.com', + firstName: 'test', + lastName: 'user', + phone: '1234567890', + username: 'testuser', + userStatus: 1, + profileImg: 'profile_picture_url', + }; + + jest + .spyOn(prismaService.user, 'findUnique') + .mockResolvedValueOnce(mockUser); + + const result = await authService.getUserDetails(mockEmail); + + expect(result).toEqual(mockUser); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..c0f862f --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,135 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { User } from '@prisma/client'; +import axios from 'axios'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AccessToken, + AuthorizationOptions, + ExchangeResponse, + UserDTO, +} from './types'; + +@Injectable({}) +export class AuthService { + constructor(private prisma: PrismaService) {} + + getAuthorizationUrl(options: AuthorizationOptions): string { + const rootUrl = 'https://accounts.google.com/o/oauth2/v2/auth'; + const optionsObj = { + redirect_uri: 'http://localhost:8000/google', + client_id: process.env.OAUTH_CLIENT_ID || '', + access_type: 'offline', + response_type: 'code', + prompt: 'consent', + scope: options?.scopes.join(' '), + state: options?.state || '', + }; + const qs = new URLSearchParams(optionsObj); + return `${rootUrl}?${qs.toString()}`; + } + + async exchangeAuthorizationCode( + authorizationCode: string, + ): Promise { + // get code from url from frontend + const url = 'https://oauth2.googleapis.com/token'; + const values: any = { + code: authorizationCode, + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + redirect_uri: 'http://localhost:8000/google', + grant_type: 'authorization_code', + }; + const response = await axios.post(url, new URLSearchParams(values), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + const userinfo = await axios.get( + 'https://www.googleapis.com/oauth2/v3/userinfo?alt=json', + { + headers: { + Authorization: `Bearer ${response.data.access_token}`, + }, + }, + ); + // Here we create a user if does not exist and if does then we do nothing + await this.prisma.user.upsert({ + where: { + email: userinfo.data.email, + }, + create: { + email: userinfo.data.email, + profileImg: userinfo.data.picture, + }, + update: {}, + }); + return response.data; + } + + async refreshAccessToken(refreshToken: string): Promise { + const tokenUrl = 'https://oauth2.googleapis.com/token'; + const values: any = { + refresh_token: refreshToken, + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + grant_type: 'refresh_token', + }; + const response = await axios.post(tokenUrl, new URLSearchParams(values), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + const accessToken: AccessToken = { + token: response.data.access_token, + expiresAt: new Date(Date.now() + response.data.expires_in * 1000), + refreshToken: response.data.refresh_token || refreshToken, + }; + return accessToken; + } + + async revokeToken(token: string): Promise { + const revokeUrl = 'https://accounts.google.com/o/oauth2/revoke'; + const params = new URLSearchParams(); + params.append('token', token); + await axios.get(revokeUrl, { + params: params, + }); + } + + async updateUserDetails(email: string, data: UserDTO): Promise { + const { firstName, lastName, phone, username } = data; + const user = await this.prisma.user.findUnique({ + where: { + email: email, + }, + }); + if (!user) { + throw new HttpException(`Failed to update user details`, 500); + } + const updatedUser = await this.prisma.user.update({ + where: { + email: email, + }, + data: { + firstName: firstName, + lastName: lastName, + phone: phone, + username: username, + }, + }); + return updatedUser; + } + + async getUserDetails(email: string) { + const user = await this.prisma.user.findUnique({ + where: { + email: email, + }, + }); + if (!user) { + throw new HttpException(`User not found with email: ${email}`, 404); + } + return user; + } +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..c7bed40 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,139 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export interface OAuthService { + getAuthorizationUrl(options: AuthorizationOptions): string; + exchangeAuthorizationCode(authorizationCode: string): Promise; + refreshAccessToken(refreshToken: string): Promise; + revokeToken(token: string): Promise; +} + +export interface AuthorizationOptions { + scopes: string[]; + state?: string; + // Additional provider-specific options can be added here +} + +export class AccessToken { + @ApiProperty({ + description: 'Access token', + }) + token: string; + @ApiProperty({ + description: 'Expires at', + }) + expiresAt: Date; + @ApiProperty({ + description: 'Refresh token', + }) + refreshToken?: string; +} + +export class ExchangeResponse { + @ApiProperty({ + description: 'Access token', + }) + access_token: string; + @ApiProperty({ + description: 'Expires in', + }) + expires_in: number; + @ApiProperty({ + description: 'Scopes', + }) + scope?: string; + @ApiProperty({ + description: 'Refresh token', + }) + refresh_token: string; + @ApiProperty({ + description: 'Token type', + }) + token_type?: string; + @ApiProperty({ + description: 'Id token', + }) + id_token?: string; +} + +export class UserDTO { + @ApiProperty({ + description: 'Update User first name', + }) + firstName?: string; + @ApiProperty({ + description: 'Update User last name', + }) + lastName?: string; + @ApiProperty({ + description: 'Update User phone', + }) + phone?: string; + @ApiProperty({ + description: 'Update User username', + }) + username?: string; +} + +export class AuthURL { + @ApiProperty({ + description: 'Authorization URL', + }) + url: string; +} + +export class UserResponse { + @ApiProperty({ + description: 'User id', + }) + id: number; + @ApiProperty({ + description: 'User email', + }) + email: string; + @ApiProperty({ + description: 'User profile image', + }) + profileImg?: string; + @ApiProperty({ + description: 'User first name', + }) + firstName?: string; + @ApiProperty({ + description: 'User last name', + }) + lastName?: string; + @ApiProperty({ + description: 'User phone', + }) + phone?: string; + @ApiProperty({ + description: 'User username', + }) + username?: string; + @ApiProperty({ + description: 'User status', + }) + userStatus: number; +} + +export class ExchangeToken { + @ApiProperty({ + description: + 'Authorization code to exchange for access token and refresh token', + }) + code: string; +} + +export class RefreshToken { + @ApiProperty({ + description: 'Refresh token to refresh access token', + }) + refreshToken: string; +} + +export class RevokeToken { + @ApiProperty({ + description: 'Access token to revoke', + }) + accessToken: string; +} diff --git a/tsconfig.json b/tsconfig.json index 5251a0d..fa46851 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, + "strictPropertyInitialization": false, "strict": true }, "exclude": ["node_modules"]