diff --git a/example.env b/example.env index a9f6191..cb6a3fe 100644 --- a/example.env +++ b/example.env @@ -17,4 +17,8 @@ JWT_ACCESS_EXPIRATION=864000 JWT_REFRESH_SECRET=SWVG1ACUJwVfIyeBe8iGPugAIPq2dNshecqazIVqvK0zb6xJFGGGpoB8naJuOCatH4q+lE57L093HIBm9iZVCd8GfTGsXULaij0k4IU4SSQ/9yyp5qiTWJKnjsmfJc2/FX8xr6XL7chCX8tHgSye5clffIQIY0LlVCwUbC4CukZY8ScSs980EXqnwk63b6R4z+ULYdjPxMk5GQB/qHgJnpa3oFIdCirFtUQUaQY8JpLU6qArDN2LelAcg3g1Eilo4fDNMvDtjNtsRxWYt4zL8Gmf4Mt2lfrrfrKxShd8ITD/4z+zy0GS5Uxg3rD2iVj4E3kjrQv5CD8zhOOg5xA1NA== JWT_REFRESH_EXPIRATION=90 +# GOOGLE +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + CORS_VALID_ORIGINS=localhost,ngrok-free \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f69c99f..f5b8684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "google-auth-library": "^9.5.0", "lodash": "^4.17.21", "moment": "^2.30.1", "mongoose": "^8.1.0", @@ -3316,6 +3317,14 @@ "node": ">= 10.0.0" } }, + "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==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -3490,8 +3499,7 @@ "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "dev": true + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -4196,7 +4204,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dev": true, "dependencies": { "safe-buffer": "^5.0.1" } @@ -4697,6 +4704,11 @@ "node": ">= 0.8" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -5123,6 +5135,73 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/gaxios": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.1.1.tgz", + "integrity": "sha512-bw8smrX+XlAoo9o1JAksBwX+hi/RG15J+NTSxmNPIclKC3ZVK6C2afwY8OSdRvOK0+ZLecUJYtj2MmjOt3Dm0w==", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5274,6 +5353,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.5.0.tgz", + "integrity": "sha512-OUbP509lWVlZxuMY+Cgomw49VzZFP9myIcVeYEpeBlbXJbPC4R+K4BmO9hd3ciYM5QIwm5W1PODcKjqxtkye9Q==", + "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/gcp-metadata": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz", + "integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==", + "dependencies": { + "gaxios": "^6.0.0", + "json-bigint": "^1.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==", + "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==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -5297,6 +5423,37 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gtoken": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.0.1.tgz", + "integrity": "sha512-KcFVtoP1CVFtQu0aSk3AyAt2og66PFhZAlkUOuWKwzMLoulHXG5W5wE5xAnHb+yl3/wEFoqGW7/cDGMU8igDZQ==", + "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==", + "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==", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5645,7 +5802,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, "engines": { "node": ">=8" }, @@ -6430,6 +6586,14 @@ "node": ">=4" } }, + "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==", + "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/package.json b/package.json index ec55e5d..72adcdb 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "google-auth-library": "^9.5.0", "lodash": "^4.17.21", "moment": "^2.30.1", "mongoose": "^8.1.0", diff --git a/src/auth/controllers/customer.controller.ts b/src/auth/controllers/customer.controller.ts index 6a9fb5f..a2c1a84 100644 --- a/src/auth/controllers/customer.controller.ts +++ b/src/auth/controllers/customer.controller.ts @@ -1,13 +1,13 @@ import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common' import { ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiOkResponse, ApiTags } from '@nestjs/swagger' import { ErrorResponse, SuccessDataResponse } from '@common/contracts/dto' -import { LoginReqDto } from '@auth/dto/login.dto' +import { GoogleLoginReqDto, LoginReqDto } from '@auth/dto/login.dto' import { AuthService } from '@auth/services/auth.service' import { TokenResDto } from '@auth/dto/token.dto' import { UserSide } from '@common/contracts/constant' import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard' import { RegisterReqDto } from '@auth/dto/register.dto' -import { DataResponse } from '@src/common/contracts/openapi-builder' +import { DataResponse } from '@common/contracts/openapi-builder' @ApiTags('Auth - Customer') @Controller('customer') @@ -19,9 +19,14 @@ export class AuthCustomerController { @ApiOkResponse({ type: DataResponse(TokenResDto) }) @ApiBadRequestResponse({ type: ErrorResponse }) async login(@Body() loginReqDto: LoginReqDto): Promise { - const res = await this.authService.login(loginReqDto, UserSide.CUSTOMER) + return this.authService.login(loginReqDto, UserSide.CUSTOMER) + } - return res + @Post('google') + @ApiOkResponse({ type: DataResponse(TokenResDto) }) + @ApiBadRequestResponse({ type: ErrorResponse }) + async googleLogin(@Body() googleLoginReqDto: GoogleLoginReqDto): Promise { + return this.authService.googleLogin(googleLoginReqDto) } @Post('register') diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index 87e3c98..d96c538 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -10,3 +10,9 @@ export class LoginReqDto { @IsNotEmpty() password: string; } + +export class GoogleLoginReqDto { + @ApiProperty() + @IsNotEmpty() + token: string; +} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index fb598bf..b50f575 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -22,13 +22,4 @@ export class RegisterReqDto { @IsNotEmpty() @IsStrongPassword() password: string - - @ApiProperty() - @IsNotEmpty() - @Matches(PHONE_REGEX) - phone: string - - @ApiProperty() - @IsNotEmpty() - address: string } diff --git a/src/auth/services/auth.service.ts b/src/auth/services/auth.service.ts index 21f3a28..a49e7dc 100644 --- a/src/auth/services/auth.service.ts +++ b/src/auth/services/auth.service.ts @@ -1,6 +1,6 @@ import { JwtService } from '@nestjs/jwt' import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common' -import { LoginReqDto } from '@auth/dto/login.dto' +import { GoogleLoginReqDto, LoginReqDto } from '@auth/dto/login.dto' import { CustomerRepository } from '@customer/repositories/customer.repository' import { Errors } from '@common/contracts/error' import { Customer } from '@customer/schemas/customer.schema' @@ -13,6 +13,8 @@ import { ConfigService } from '@nestjs/config' import { RegisterReqDto } from '@auth/dto/register.dto' import { StaffRepository } from '@staff/repositories/staff.repository' import { Staff } from '@staff/schemas/staff.schema' +import { SuccessResponse } from '@common/contracts/dto' +import { OAuth2Client } from 'google-auth-library' @Injectable() export class AuthService { @@ -38,11 +40,11 @@ export class AuthService { } if (side === UserSide.PROVIDER) { - user = (await this.staffRepository.findOne({ + user = await this.staffRepository.findOne({ conditions: { email: loginReqDto.email } - })) + }) userRole = user?.role } @@ -65,6 +67,48 @@ export class AuthService { } } + public async googleLogin(googleLoginReqDto: GoogleLoginReqDto): Promise { + const client = new OAuth2Client({ + clientId: this.configService.get('GOOGLE_CLIENT_ID'), + clientSecret: this.configService.get('GOOGLE_CLIENT_SECRET') + }) + + const ticket = await client.verifyIdToken({ + idToken: googleLoginReqDto.token + }) + + const payload = ticket.getPayload() + + const googleUserId = payload.sub + + const user = await this.customerRepository.findOne({ + conditions: { + googleUserId: googleUserId + } + }) + + if (!user) { + await this.customerRepository.create({ + firstName: payload.given_name, + lastName: payload.family_name, + email: payload.email, + avatar: payload.picture, + googleUserId: googleUserId + }) + } + + const accessTokenPayload: AccessTokenPayload = { name: user.firstName, sub: user._id, role: UserRole.CUSTOMER } + + const refreshTokenPayload: RefreshTokenPayload = { sub: user._id, role: UserRole.CUSTOMER } + + const tokens = this.generateTokens(accessTokenPayload, refreshTokenPayload) + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + } + } + public async register(registerReqDto: RegisterReqDto) { const customer = await this.customerRepository.findOne({ conditions: { @@ -80,12 +124,10 @@ export class AuthService { firstName: registerReqDto.firstName, lastName: registerReqDto.lastName, email: registerReqDto.email, - password, - phone: registerReqDto.phone, - address: [registerReqDto.address] + password }) - return { success: true } + return new SuccessResponse(true) } public async refreshAccessToken(id: string, side: UserSide): Promise { diff --git a/src/config/index.ts b/src/config/index.ts index 2512525..ad80b3b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -15,7 +15,9 @@ export default () => ({ JWT_ACCESS_SECRET: process.env.JWT_ACCESS_SECRET || 'accessSecret', JWT_ACCESS_EXPIRATION: process.env.JWT_ACCESS_EXPIRATION || 864000, // seconds JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || 'refreshSecret', - JWT_REFRESH_EXPIRATION: Number(process.env.JWT_REFRESH_EXPIRATION) || 90 // 90 days + JWT_REFRESH_EXPIRATION: Number(process.env.JWT_REFRESH_EXPIRATION) || 90, // 90 days + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, }) export const AuthRoles = { diff --git a/src/customer/schemas/customer.schema.ts b/src/customer/schemas/customer.schema.ts index f42b97b..3a61216 100644 --- a/src/customer/schemas/customer.schema.ts +++ b/src/customer/schemas/customer.schema.ts @@ -38,31 +38,18 @@ export class Customer { @ApiProperty() @Prop({ type: String, - validate: { - validator: (v: string) => { - return EMAIL_REGEX.test(v) - }, - message: (props) => `${props.value} is not a valid email!` - }, required: true }) email: string @ApiProperty() @Prop({ - type: String, - validate: { - validator: (v: string) => { - return PHONE_REGEX.test(v) - }, - message: (props) => `${props.value} is not a valid phone number!` - }, - required: true + type: String }) phone: string @ApiProperty() - @Prop({ type: Array, required: true }) + @Prop({ type: Array }) address: string[] @ApiProperty() @@ -75,24 +62,22 @@ export class Customer { @ApiProperty() @Prop({ - type: String, - validate: { - validator: (v: string) => { - return URL_REGEX.test(v) - }, - message: (props) => `${props.value} is not a valid image url!` - } + type: String }) avatar: string @ApiProperty() - @Prop({ type: String, required: true }) + @Prop({ type: String }) password: string @ApiProperty() @Prop({ type: Date, default: Date.now() }) lastLoginDate: Date + @ApiProperty() + @Prop({ type: String }) + googleUserId: string + @Prop({ enum: Status, default: Status.ACTIVE