diff --git a/backend/.env.example b/backend/.env.example index c8532ba..eecdf9e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,6 +20,8 @@ JWT_ADMIN_SECRET=super-secret-admin-jwt-key JWT_ADMIN_EXPIRATION=15m JWT_ADMIN_REFRESH_SECRET=super-secret-admin-refresh-key JWT_ADMIN_REFRESH_EXPIRATION=7d +GOOGLE_OAUTH_CLIENT_ID=your-google-client-id, +GOOGLE_OAUTH_CLEINT_SECRET=your-google-client-secrete # Application PORT=3000 diff --git a/backend/config/jwt.config.ts b/backend/config/jwt.config.ts index f71fd56..1423898 100644 --- a/backend/config/jwt.config.ts +++ b/backend/config/jwt.config.ts @@ -9,4 +9,6 @@ export default registerAs('jwt', () => ({ process.env.JWT_REFRESH_TOKEN_TTL ?? '7776000', 10, ), + googleClient_id: process.env.GOOGLE_OAUTH_CLIENT_ID, + googleClient_secret: process.env.GOOGLE_OAUTH_CLEINT_SECRET, })); diff --git a/backend/package-lock.json b/backend/package-lock.json index 095554d..78acdff 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "dep": "^0.18.3", "dotenv": "^16.4.7", "ejs": "^3.1.10", + "google-auth-library": "^9.15.1", "handlebars": "^4.7.8", "ioredis": "^5.5.0", "jsonwebtoken": "^9.0.2", @@ -3758,6 +3759,15 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bignumber.js": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -16705,6 +16715,12 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extend-object": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/extend-object/-/extend-object-1.0.0.tgz", @@ -17192,6 +17208,71 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -17348,6 +17429,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-auth-library/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-auth-library/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -17374,6 +17502,40 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/gtoken/node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -17961,7 +18123,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -18954,6 +19115,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index e4b3b3a..27f03e1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,8 +25,8 @@ "migration:run": "npm run typeorm -- migration:run -d ./src/data-source.ts" }, "dependencies": { - "@nestjs/cache-manager": "^3.0.0", "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/cache-manager": "^3.0.0", "@nestjs/common": "^11.0.10", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.10", @@ -43,9 +43,10 @@ "class-validator": "^0.14.1", "dep": "^0.18.3", "dotenv": "^16.4.7", - "ioredis": "^5.5.0", "ejs": "^3.1.10", + "google-auth-library": "^9.15.1", "handlebars": "^4.7.8", + "ioredis": "^5.5.0", "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 1d56ff3..e28a5b8 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -13,6 +13,8 @@ import { RefreshTokenProvider } from './providers/refresh-token.provider'; import { PassportModule } from '@nestjs/passport'; import { JwtStrategy } from '../../security/strategies/jwt.strategy'; import { SubAdminModule } from 'src/sub-admin/sub-admin.module'; +import { GoogleAuthenticationController } from './social/google-authtication.controller'; +import { GoogleAuthenticationService } from './social/providers/google-authtication'; @Module({ imports: [ @@ -25,7 +27,7 @@ import { SubAdminModule } from 'src/sub-admin/sub-admin.module'; signOptions: { expiresIn: '1h' }, }), ], - controllers: [AuthController], + controllers: [AuthController, GoogleAuthenticationController], providers: [ AuthService, JwtStrategy, @@ -36,6 +38,7 @@ import { SubAdminModule } from 'src/sub-admin/sub-admin.module'; SignInProvider, GenerateTokenProvider, RefreshTokenProvider, + GoogleAuthenticationService ], exports: [ AuthService, diff --git a/backend/src/auth/social/dtos/google-token-dto.ts b/backend/src/auth/social/dtos/google-token-dto.ts new file mode 100644 index 0000000..407327c --- /dev/null +++ b/backend/src/auth/social/dtos/google-token-dto.ts @@ -0,0 +1,6 @@ +import {IsNotEmpty} from 'class-validator' + +export class GoogleTokenDto { + @IsNotEmpty() + token: string +} \ No newline at end of file diff --git a/backend/src/auth/social/google-authtication.controller.ts b/backend/src/auth/social/google-authtication.controller.ts new file mode 100644 index 0000000..a43b485 --- /dev/null +++ b/backend/src/auth/social/google-authtication.controller.ts @@ -0,0 +1,18 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { GoogleAuthenticationService } from './providers/google-authtication'; +import { GoogleTokenDto } from './dtos/google-token-dto'; + +@Controller('auth') +export class GoogleAuthenticationController { + constructor( + /* + * inject googleAuthenticationService + */ + private readonly googleAuthenticationService: GoogleAuthenticationService + ) {} + + @Post('google-authentication') + public authenticate(@Body() googlTokenDto: GoogleTokenDto) { + return this.googleAuthenticationService.authenticate(googlTokenDto) + } +} diff --git a/backend/src/auth/social/google-authtication.module.ts b/backend/src/auth/social/google-authtication.module.ts new file mode 100644 index 0000000..9fea196 --- /dev/null +++ b/backend/src/auth/social/google-authtication.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GoogleAuthenticationController } from './google-authtication.controller'; +import { GoogleAuthenticationService } from './providers/google-authtication'; + +@Module({ + controllers: [GoogleAuthenticationController], + providers: [GoogleAuthenticationService] +}) + +export class GoogleAuthticationModule {} diff --git a/backend/src/auth/social/interfaces/user.interface.ts b/backend/src/auth/social/interfaces/user.interface.ts new file mode 100644 index 0000000..5d540c8 --- /dev/null +++ b/backend/src/auth/social/interfaces/user.interface.ts @@ -0,0 +1,6 @@ +export interface GoogleInterface { + email: string + firstName: string + lastName: string + googleId: string +} \ No newline at end of file diff --git a/backend/src/auth/social/providers/google-authtication.ts b/backend/src/auth/social/providers/google-authtication.ts new file mode 100644 index 0000000..0de5a24 --- /dev/null +++ b/backend/src/auth/social/providers/google-authtication.ts @@ -0,0 +1,76 @@ +import { forwardRef, Inject, Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common'; +import { ConfigType } from '@nestjs/config'; +import { OAuth2Client } from 'google-auth-library'; +import jwtConfig from 'config/jwt.config'; +import { GoogleTokenDto } from '../dtos/google-token-dto'; +import { GenerateTokenProvider } from 'src/auth/providers/generate-token.provider'; +import { UsersService } from 'src/users/users.service'; + +@Injectable() +export class GoogleAuthenticationService implements OnModuleInit { + private oAuthClient: OAuth2Client; + constructor( + /** + * inject userService + */ + @Inject(forwardRef(() => UsersService)) + private readonly userService: UsersService, + + /** + * inject jwtconfig + */ + @Inject(jwtConfig.KEY) + private readonly jwtConfigurattion: ConfigType, + /** + * inject generateTokensProvider + */ + private readonly generateTokensProvider: GenerateTokenProvider, + ) {} + + onModuleInit() { + const client_id = this.jwtConfigurattion.googleClient_id; + const client_secret = this.jwtConfigurattion.googleClient_secret; + + this.oAuthClient = new OAuth2Client(client_id, client_secret); + } + public async authenticate(googleTokenDto: GoogleTokenDto) { + try { + console.log("Received Token:", googleTokenDto.token); + + // verify the google token sent by user + const loginTicket = await this.oAuthClient.verifyIdToken({ + idToken: googleTokenDto.token, + }); + + console.log("Google Token Payload:", loginTicket.getPayload()); + + // extract the payload from google jwt token + const { + email, + sub: googleId, + given_name: firstName, + family_name: lastName, + } = loginTicket.getPayload(); + + // find the user in the database using googleId + const user = await this.userService.findOneByGoogleId(googleId); + + // if user exist, generate token + if (user) { + return this.generateTokensProvider.generateTokens(user); + } + // else generate the user and create the token + const newUser = await this.userService.createGoogleUser({ + email: email, + firstName: firstName, + lastName: lastName, + googleId: googleId, + }); + return this.generateTokensProvider.generateTokens(newUser); + } catch (error) { + // if any of the step fails, send an unauthorised exception + console.error("Google Auth Error:", error); + throw new UnauthorizedException('failed to authenticate with google'); + } + } +} \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index be23348..1326109 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -45,6 +45,15 @@ async function bootstrap() { new AllExceptionsFilter(), ); + + // enable cors + app.enableCors({ + origin: 'http://localhost:3500/', // All locations + credentials: true, // Allow cookies + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Allowed methods + allowedHeaders: ['Content-Type', 'Authorization'], + }); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts index 377f15c..1491240 100644 --- a/backend/src/users/entities/user.entity.ts +++ b/backend/src/users/entities/user.entity.ts @@ -47,6 +47,9 @@ export class User { }) results: Result[]; + @Column('varchar', { length: 225, nullable: true }) + googleId?: string + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/users/providers/create-google-user-provider.ts b/backend/src/users/providers/create-google-user-provider.ts new file mode 100644 index 0000000..2abecd4 --- /dev/null +++ b/backend/src/users/providers/create-google-user-provider.ts @@ -0,0 +1,27 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { GoogleInterface } from 'src/auth/social/interfaces/user.interface'; +import { User } from '../entities/user.entity'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class CreateGoogleUserProvider { + constructor( + /* + * inject userRepository + */ + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + public async createGoogleUser(googleUser: GoogleInterface) { + try { + const user = this.userRepository.create(googleUser); + return await this.userRepository.save(user); + } catch (error) { + throw new ConflictException(error, { + description: 'could not create a new user', + }); + } + } +} diff --git a/backend/src/users/providers/find-one-by-google-id-provider.ts b/backend/src/users/providers/find-one-by-google-id-provider.ts new file mode 100644 index 0000000..cc8bbf5 --- /dev/null +++ b/backend/src/users/providers/find-one-by-google-id-provider.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { User } from '../entities/user.entity'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class FindOneByGoogleIdProvider { + constructor( + /* + *inject userRepository + */ + @InjectRepository(User) + private userRepository: Repository, + ) {} + public async findOneByGoogleId(googleId: string) { + return await this.userRepository.findOneBy({ googleId }) + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index d8d3cff..19a6a2a 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -9,6 +9,8 @@ import { FindOneByEmailProvider } from './providers/find-one-by-email.provider'; import { LeaderboardModule } from 'src/leaderboard/leaderboard.module'; import { ResultService } from 'src/result/result.service'; import { ResultModule } from 'src/result/result.module'; +import { FindOneByGoogleIdProvider } from './providers/find-one-by-google-id-provider'; +import { CreateGoogleUserProvider } from './providers/create-google-user-provider'; @Module({ imports: [ @@ -24,6 +26,8 @@ import { ResultModule } from 'src/result/result.module'; CreateUsersProvider, FindOneByEmailProvider, ResultService, + FindOneByGoogleIdProvider, + CreateGoogleUserProvider, ], exports: [UsersService, ResultService], }) diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 0a1bfb8..e6f4f28 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -11,6 +11,9 @@ import { CreateUsersProvider } from './providers/create-users-provider'; import { Repository } from 'typeorm'; import { FindOneByEmailProvider } from './providers/find-one-by-email.provider'; import { UpdateUserDto } from './dto/update-user.dto'; +import { FindOneByGoogleIdProvider } from './providers/find-one-by-google-id-provider'; +import { CreateGoogleUserProvider } from './providers/create-google-user-provider'; +import { GoogleInterface } from 'src/auth/social/interfaces/user.interface'; @Injectable() export class UsersService { @@ -23,6 +26,10 @@ export class UsersService { private readonly findOneByEmailProvider: FindOneByEmailProvider, + private readonly findOneByGoogleIdProvider: FindOneByGoogleIdProvider, + + private readonly createGoogleUserProvider: CreateGoogleUserProvider, + private readonly createUserProvider: CreateUsersProvider, // @Inject(forwardRef(() => AuthService)) @@ -69,6 +76,14 @@ export class UsersService { return await this.userRepository.findOneBy({ id }); } + public async findOneByGoogleId(googleId: string) { + return this.findOneByGoogleIdProvider.findOneByGoogleId(googleId); + } + + public async createGoogleUser(googleUser: GoogleInterface) { + return this.createGoogleUserProvider.createGoogleUser(googleUser); + } + // update(id: number, updateUserDto: UpdateUserDto) { // return `This action updates a #${id} user`; // }