+2

Authentication Google One Tap, Nextjs 14 và Nestjs

Google One Tap cho phép người dùng nhấn 1 nút duy nhất để xác thực trong ứng dụng của bạn bằng tài khoản Google.

Bài viết này nói về việc triển khai tính năng đăng nhập one-tap của Google và hiển thị nút đăng nhập được cá nhân hóa bằng Google trong Nextjs 14 mà không cần bất kỳ thư viện bên ngoài nào.

Trong bài viết này mình sẽ sử dụng Nestjs làm backend.

Giới thiệu

Gần đây team mình đã triển khai đăng nhập một chạm của Google để cải thiện trải nghiệm người dùng của Homeei. Mình nghĩ đây sẽ là cơ hội tuyệt vời để chia sẻ các làm thế nào để bạn có thể làm được.

Mình sẽ có hết sức để giải thích cách bạn có thể tích hợp đăng nhập one-tap của Google vào Nextjs với Nestjs làm backend mà không cần dựa vào các thư viện frontend bên ngoài.

Chuẩn bị

Trước hết bạn phải đăn ký tài khoản Google Developer và và lấy CLIENT_ID và CLIENT_SECRET, bạn có thể xem hướng dẫn tại: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid

Bạn sẽ phải lưu lại hai thông tin này để sử dụng trong các bước tiếp theo

Cài đặt Project Nextjs

Mình sẽ làm 1 ví dụ đơn giản với NextJS để bạn có thể hình dung được việc tích hợp diễn ra như thế nào

Đầu tiên tất nhiên là tạo một project mới, mình sẽ tạo bằng công cụ create-next-app:

npx create-next-app@latest

#  Create Next App can be installed via yarn:
yarn create next-app

# Create Next App can be installed via npm:
npm create next-app --ts

Cấu hình project:

√ What is your project named? ... my-app
√ Would you like to use ESLint? ... No / Yes
√ Would you like to use Tailwind CSS? ... No / Yes
√ Would you like to use `src/` directory? ... No / Yes
√ Would you like to use App Router? (recommended) ... No / Yes
√ Would you like to customize the default import alias (@/*)? ... No / Yes

Sau khi cài đặt và chạy được project, chúng ta sẽ tạo file .env để lưu thông tin google identity với nội dung:

# đường dẫn đến backend
NEXT_PUBLIC_BACKEND_URL=http://localhost:6001/rs/v1 
# client id mà chúng ta đã lấy ở trên
NEXT_PUBLIC_GOOGLE_OAUTH_CLIENT_ID=xxxx-xxxxx.apps.googleusercontent.com

Thêm đoạn script của google vào trong file layout.tsx:

<html lang="en" suppressHydrationWarning>
        <head>
            <AdSense/>
        </head>
        <body>
        {children}
        {/*đoạn cần thêm */}
        <Script src="https://accounts.google.com/gsi/client" strategy="beforeInteractive" />
        {/*-----------------*/}
        </body>
        </html>

Tạo file component GoogleOneTapLogin.tsx trong thư mục ui (app hoặc components), nơi bạn có thể sử dụng cho nhiều trang với nội dung như sau:

'use client'
import { useEffect } from 'react';
import {BASE_URL} from "@/api/base-repository";
import axios from "axios";
import {useRouter} from "next/navigation";

const googleOneTapLogin = (data: any) => {
    const path = BASE_URL + '/auth/google/one-tap';
    return axios.post(path, data);
};

const GoogleOneTapLogin = () => {
    const router = useRouter();

    useEffect(() => {
        // will show popup after two secs
        const timeout = setTimeout(() => oneTap(), 2000);
        return () => {
            clearTimeout(timeout);
        };
    }, []);

    const oneTap = () => {
        if (typeof window === 'undefined' || !window || localStorage.get('token')) return
        console.log('init one tap')
        const { google } = window;
        if (google) {
            google.accounts.id.initialize({
                client_id: process.env.NEXT_PUBLIC_GOOGLE_OAUTH_CLIENT_ID,
                callback: async (response: any) => {
                    call(response);
                },
            });

            google.accounts.id.prompt();

            google.accounts.id.prompt((notification: any) => {
                console.log(notification);
                if (notification.isNotDisplayed()) {
                    console.log(
                        'getNotDisplayedReason ::',
                        notification.getNotDisplayedReason()
                    );
                } else if (notification.isSkippedMoment()) {
                    console.log('getSkippedReason  ::', notification.getSkippedReason());
                } else if (notification.isDismissedMoment()) {
                    console.log(
                        'getDismissedReason ::',
                        notification.getDismissedReason()
                    );
                }
            });
        }
    };

    const call = async (resp: any) => {
        try {
            const res = await googleOneTapLogin(resp);
            // xử lý response trả về
            router.push('/user');
        } catch (error) {
            console.debug(error)
            router.push('/auth/sign-in');
        }
    };
    return <div />;
};

export default GoogleOneTapLogin;

Mình sẽ giải thích một số yếu tố quan trong trong file cấu hình này:

  • BASEURL: đường dẫn của backend là thuộc tính NEXTPUBLICBACKENDURL mà chúng ta đã lưu trong file .env ở trên
  • BASE_URL + '/auth/google/one-tap': api gọi validate xuống dưới backend. Bước tiếp theo mình sẽ hướng dẫn viết api này.
  • if (typeof window === 'undefined' || !window || localStorage.get('token')) return Vì nextjs có cả server side render nên mình để điều kiện này kiểm tra tránh gây ra lỗi cho web Ngoài ra, mình lưu token trong localStorage nên kiểm tra điều kiện token tránh google one-tap hiện quá nhiều lần.

Tiếp theo bạn có thể sử dụng thành phần này trong bất kỳ page nào cần hiển thị google one-tap.

<div>
    ...
    <GoogleOneTapLogin />
    ...
</div>

Cài đặt project Nestjs

Đầu tiên, chúng ta khởi tạo ứng dụng có tên là news-app, sử dụng công cụ nest-cli.

# cai dat cli
npm install -g @nestjs/cli

# khoi tao project
nest new news-app

Tạo module xác thực:

nest g module auth
nest g controller auth
nest g service auth

Tiếp theo, chúng ta sẽ cài đặt thêm thư viện cần thiết bằng lệnh sau:

npm install --save @nestjs/jwt @nestjs/passport passport passport-jwt passport-google-one-tap
npm install --save-dev @types/passport-jwt

Xây dựng chức năng xác thực với passport-google-one-tap

Tạo file google-one-tap.guard.ts theo đường dẫn src/auth/strategy/google-one-tap.guard.ts với nội dung như sau:

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthGuard } from '@nestjs/passport';
import { PassportStrategyEnum } from '@reakestate/stateless';

@Injectable()
export class GoogleOneTapGuard extends AuthGuard(
  PassportStrategyEnum.GOOGLE_ONE_TAP,
) {
  constructor(private configService: ConfigService) {
    super({
      accessType: 'offline',
    });
  }
}

Với PassportStrategyEnum là enum bạn tự định nghĩa với các giá trị tên cho từng Stragegy.

Tạo file google-one-tap.strategy.ts theo đường dẫn src/auth/strategy/google-one-tap.strategy.ts với nội dung:

import { GoogleOneTapStrategy as GG } from 'passport-google-one-tap';
import { Injectable } from '@nestjs/common';
import { AuthService } from '@app/auth/auth.service';
import { AuthProviderEnum, PassportStrategyEnum } from '@reakestate/stateless';
import * as process from 'process';
import { PassportStrategy } from '@nestjs/passport';

@Injectable()
export class GoogleOneTapStrategy extends PassportStrategy(
  GG,
  PassportStrategyEnum.GOOGLE_ONE_TAP,
) {
  constructor(private authService: AuthService) {
    super(
      {
        clientID: process.env.GOOGLE_CLIENT_ID,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET,
        verifyCsrfToken: false,
      },
      async (
        profile: {
          provider: string;
          id: string;
          displayName: string;
          username?: string | undefined;
          name?:
            | {
                familyName: string;
                givenName: string;
                middleName?: string | undefined;
              }
            | undefined;
          emails?:
            | Array<{
                value: string;
                type?: string | undefined;
              }>
            | undefined;
          photos?:
            | Array<{
                value: string;
              }>
            | undefined;
        },
        done: any,
      ) => {
        try {
          const response = await this.authService.authenticateOneTapGoogle(
            AuthProviderEnum.GOOGLE,
            profile,
          );

          done(null, {
            email: profile.emails[0].value,
            token: response.token,
            newUser: response.newUser,
          });
        } catch (err) {
          done(err, false);
        }
      },
    );
  }
}

Với process.env.GOOGLE_CLIENT_IDprocess.env.GOOGLE_CLIENT_SECRET đã có trong bước chuẩn bị.

Trong file auth.service.ts sẽ có hàm để xác thực người dùng (tạo mới + kiểm tra tồn tại và tạo token mới cho người dùng). Trong ứng dụng này chúng ta sẽ sử dụng token tự gen bằng backend.

@Injectable()
export class AuthService {
    ...
    constructor(
        private jwtService: JwtService,
        private readonly userRepository: UserRepository,
    ) {}
    ...
    public async authenticateOneTapGoogle(
    authProvider: AuthProviderEnum,
    profile: {
      provider: string;
      id: string;
      displayName: string;
      username?: string | undefined;
      name?:
        | {
            familyName: string;
            givenName: string;
            middleName?: string | undefined;
          }
        | undefined;
      emails?:
        | Array<{
            value: string;
            type?: string | undefined;
          }>
        | undefined;
      photos?:
        | Array<{
            value: string;
          }>
        | undefined;
    },
  ) {
    const { name, emails, photos } = profile;
    const email = normalizeEmail(emails[0].value);
    let user: IUser = await this.userRepository.findByEmail(email);
    let newUser = false;

    if (!user) {
      const firstName = name.givenName;
      const lastName = name.familyName;

      user = await this.userRepository.save({
        profilePicture: photos[0].value,
        username: email,
        email,
        firstName,
        lastName,
        plan: UserPlan.free,
        billingCode: makeid(10).toUpperCase(),
        tokens: [
          {
            provider: authProvider,
            valid: true,
          },
        ],
      });
      newUser = true;
    } else {
      if (authProvider === AuthProviderEnum.GOOGLE) {
        user = await this.updateUserUsername(
          user,
          {
            email: email,
            name: profile.displayName,
            avatarUrl: photos[0].value,
            login: email,
            id: profile.id,
          },
          authProvider,
        );
      }

      await this.userRepository.update(
        {
          id: user.id,
        },
        {
          lastLogin: new Date(),
        },
      );
    }

    return {
      newUser,
      token: await this.getSignedToken(user),
    };
  }
    ...
}

Vì đoạn code trên mình lấy ra từ project hiện tại nên có một số hàm về responsitory và gen token bạn có thể tự implement trong dự án của mình.

Bạn có thể lược bỏ một số đoạn kiểm tra trong database để có thể chạy demo

Mình sẽ cung cấp code mẫu cho từng hàm này:

normalizeEmail

export function normalizeEmail(email: string): string {
 if (typeof email !== 'string') {
   throw new TypeError('normalize-email expects a string');
 }

 const lowerCasedEmail = lowerCase(email);
 const emailParts = lowerCasedEmail.split(/@/);

 if (emailParts.length !== 2) {
   return email;
 }

 let username = emailParts[0];
 let domain = emailParts[1];

 if (normalizableProviders.hasOwnProperty(domain)) {
   if (normalizableProviders[domain].hasOwnProperty('cut')) {
     username = username.replace(normalizableProviders[domain].cut, '');
   }

   if (normalizableProviders[domain].hasOwnProperty('aliasOf')) {
     domain = normalizableProviders[domain].aliasOf;
   }
 }

 return `${username}@${domain}`;
}

getSignedToken

import { JwtService } from '@nestjs/jwt';
public async getSignedToken(user: IUser): Promise<string> {
   return this.jwtService.sign(
     {
       id: user.id,
       firstName: user.firstName,
       lastName: user.lastName,
       email: user.email,
       profilePicture: user.profilePicture,
       plan: user.plan,
       roles: user.roles,
     },
     {
       expiresIn: '2 hours',
       issuer: 'real_estate_api',
     },
   );
 }

Cuối cùng tạo controller để thực hiện tất cả những thứ trên:

@Controller('auth')
@UseInterceptors(ClassSerializerInterceptor)
@ApiTags('Auth')
export class AuthController {
 constructor(private readonly authService: AuthService) {}
 @Post('/google/one-tap')
 @UseGuards(GoogleOneTapGuard)
 async googleOneTapAuth(@Req() req: any) {
   return {
       token: req.user?.token,
       newUser: req.user?.newUser,
   };
 }
}

Chạy project nestjs trên ta có đường dẫn thực hiện one-tap sau: http://localhost:6001/rs/v1/auth/google/one-tap

Xử lý giao tiếp giữa frontend - backend

Trong file GoogleOneTapLogin.tsx mà chúng ta đã tạo lúc cài đặt project Nextjs. có đoạn gọi đến backend chúng ta chưa xử lý.

Trong phần này chúng ta sẽ thực hiện nó để hoàn thiện một ứng dụng hoàn chỉnh. Đoạn code đó như sau:

...
const call = async (resp: any) => {
        try {
            const res = await googleOneTapLogin(resp);
            if (res.status === HttpStatusCode.Ok) {
                localStorage.setItem('token', res.data?.token)
                // * nếu có thông báo người dùng mới thì viết vào đây res.data?.newUser
                router.push('/')
            } else {
                router.push('/auth/sign-in');
            }
        } catch (error) {
            console.debug(error)
            router.push('/auth/sign-in');
        }
    };
...

Khi đăng nhập thành công chúng ta lưu lại token vào localStorage cho các lần gọi đến backend tiếp theo.

Hết rồi, cảm ơn các bạn đã theo dõi tới giờ và xin 1 upvote nếu như các bạn thấy bài viết hay.

Các bạn cũng có thể truy cập và đăng tin tại homeei để ủng hộ team của mình.

Lưu ý: Nếu one tab popup không xuất hiện, có thể là vì "Third-party sign-in" setting trong trình duyệt đăng bị tắt, hoặc domain bị đưa vào danh sách un-allow. Bạn có thể chỉnh lại bằng cách truy cập vào chrome://settings/content/federatedIdentityApi

Tham khảo:


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí