iT邦幫忙

0

Week16 - 用NestJS整合Line Login,一個基於OAuth2.0的OpenID Connect系統 - NestJS篇 [Server的終局之戰系列]

本文章同時發佈於:


大家好,繼上一篇介紹完OAuth2.0 & OIDC後這次要介紹的就是如何實作Line LoginNestJS整合的部分。

這篇文章會討論

  • Line Login設定
  • ngrok
  • NestJS使用CLI創建MVC架構

Line Login設定

在與NestJS整合之前,我們必須先到Line Console上面創建Providers,並創建Line Channel,以獲得Channel ID, secret等等資訊。

  1. 登入Line Console

  1. 創建Providers

  1. 創建Line Login Channel

  1. 填寫Channel name, description, Email address,並勾選Web app,最後勾選同意條款並創建

  1. 複製Channel ID, secret,等等再整合NestJS時會使用到

  1. 接下來我們要設定Callback URL,讓Line認證網站認證成功之後有有辦法把認證資訊帶回前端,而這個URL必須擁有HTTPS,所以我們必須用到ngrok

設定ngrok

ngrok是可以再開發的時候讓你免費調用HTTPS的工具

  1. 我們進入ngrok的網頁註冊登入

  2. 下載ngrok,並透過ngrok執行授權指令,最後執行./ngrok http 3000

  3. 複製ngrokURL,並貼至Line Console的Callback URL

NestJS使用CLI創建MVC架構

整體程式碼在此,大家可以Clone起來Run會比較好理解文章。

MVC架構

NestJS將MVC的分層切得很清楚,我們可以先看看最後的目錄架構:

  • views資料夾:即是V層,這個曾就是登入的網頁頁面。
  • 副檔名為controller.ts的檔案:即是C層,業務邏輯都會放在這裡,例如要如何透過API去拿取access token,這類的「應對行為」邏輯就會放在此處。
  • 副檔名為service.ts的檔案:即是M層,任何資料的來源都會來自此檔案,例如Line API、資料庫、物聯網裝置等等第三方的資料,都由此檔案提供。

所以MVC整體的概念我們可以以官網的這張圖來解釋:

圖片來自NestJS官網

Server路由

有了MVC的整體概念後,我們可以借用上一篇OIDC的流程來說明有哪些路由,我們總共有兩個分別是:

  1. /login: 回傳網頁,會生成一個Line Login的button,點選之後就會跳制Line 認證網站。
  2. /login/auth: Ling Login認證網站認證成功後會發一個名為code的代碼至line/auth,而line/authcontroller會透過此code發Post至Line API來取得access token與OIDC的ID Token。

來Run Server吧!

  1. 我把需要設定的Config都集中放到.env.example裡了,請把剛剛獲得的資訊都貼至此檔案,並將此檔案命名為.env

    LINE_API_URI=https://api.line.me
    LINE_ACCESS_URI=https://access.line.me
    LINE_CLIENT_ID=<剛剛在Line Console獲得的Line Channel ID>
    LINE_CLICLIENT_SECRET=<剛剛在Line Console獲得的Line Channel Secret>
    SERVER_URI=<ngrok的URL,記得不要把路由/login/auth也貼上來了>
    
  2. 安裝並Run Server

    $ npm install
    $ npm run start:dev
    

實際使用

  1. 在瀏覽器上瀏覽<ngrok的URL>/login

  2. 輸入自己的帳號密碼

  3. 這邊會詢問是否同意個人檔案用戶識別資訊被讀取,點選許可即可,如果需要更多的資源,就需要進入Console調整,目前只有個人檔案的access token與用來識別的OIDC而已。許可之後Line 認證網站就會開始驗證。

  4. 驗證成功後就會將以下資訊帶往前端,前端會再將這些資訊帶至後端

    // 後端必須用此code來去Line API拿取access token與OIDC ID Token
    "code": "0ajVvTdwHLV4wQR2eFPM"
    // 防止XSS攻擊的隨機亂碼
    "state": "3e2e819f603adcfc6cb3f1761293efb591af206733e149351b2d19f43d9cf9c9a78a8a746449b763e471a9"
    
  5. 後端透過code參數發送Post至Line API,拿取access tokenOIDC ID Token,這邊我有將資訊顯示在前端,不過要怎麼使用OIDC ID Token完全取決你怎麼設計Server。

    {
      // 可以用來取的使用者檔案的access token
      "access_token": "eyJhbGciOiJIUzI1NiJ9.ohEOAni8Y89mKrUGy1hrVl6oPmwsG5mcIQ1lOfAezzgy_s5tH-NNjWysXP8NEVEwlJwTR8V3XyiZsakzfd__HNdBGUK9um2AyqFroYzTSklGuEhFx3DtbDBKwZdAO1XmtSsZrcB90ka6dkqdK8BhYIIme6krvDDlsRPFV6Yg1QY.hto6BEx2C6mcDtcYQw43LK2kRpbm1mZ1fgM38xnoZFQ",
      // access token的type
      "token_type": "Bearer",
      // 當access token過期時,可以拿此token去重新拿token
      "refresh_token": "uRweZyH1XdgKhAoXnp2X",
      // access token的期效
      "expires_in": 2592000,
      // 此次要求的資源
      "scope": "openid profile",
      // OIDC取得的ID Token
      "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL2FjY2Vzcy5saW5lLm1lIiwic3ViIjoiVTAxMDZkNWVhZDFhYzYyOTdmNDZmOTYxM2RhNmIzZDRiIiwiYXVkIjoiMTY1NDMxNDI3MSIsImV4cCI6MTU5MTU1MTkzNiwiaWF0IjoxNTkxNTQ4MzM2LCJub25jZSI6IjdlMzY1NDQ2M2NiNGY4ZGZiMzY1MTQxMzU2OGI2NDY2NmRmNDlkMWMyMjg3NWExODYyYzM0ZmU5MjliM2MyYzk0ZWVlNzQ3YjdiYzJhZjlmMTU1MzA5IiwiYW1yIjpbInB3ZCJdLCJuYW1lIjoi4ZWVKCDhkJsgKeGVl-aIkeS4jeaYr-WuuOWPsyDmiJHmmK_otoXntJrlkbHlkbEiLCJwaWN0dXJlIjoiaHR0cHM6Ly9wcm9maWxlLmxpbmUtc2Nkbi5uZXQvMGhpTFkxZTh3WE5tTmtTQjVqUmwxSk5GZ05PQTRUWmpBckhDMV9CQlpNT0ZZWmYzQTlXM2twQmhaQU9GTWRLM2sxRENrdVZVSkxhd1JQIn0.GVt5zG_QjiGZT3DfguGmz9jNX3hLwbPu4DxHHITHI5Y"
    }
    
  6. 我們可以把ID Token貼到jwt.io解析參數,最重要的就是sub這個參數,這就像使用者的身分證字號,是唯一的,你可以拿此ID至你的Server帳戶系統整合。

    {
      // 發ID Token的Line API URL
      "iss": "https://access.line.me",
      // 關鍵的Line ID,可以拿此ID至你的Server帳戶系統整合
      "sub": "U0106d5ead1ac6297f46f9613da6b3d4b",
      // Line Channel ID
      "aud": "1654314271",
      // access token的過期的時間
      "exp": 1591551936,
      // access token生產的時間
      "iat": 1591548336,
      // 授權在URL中指定的值
      "nonce": "7e3654463cb4f8dfb3651413568b64666df49d1c22875a1862c34fe929b3c2c94eee747b7bc2af9f155309",
      // 使用者登入的方法,pwd只使用帳密登入
      "amr": [
        "pwd"
      ],
      // 使用者名稱
      "name": "ᕕ( ᐛ )ᕗ我不是宸右 我是超級呱呱",
      // 使用者大頭貼
      "picture": "https://profile.line-scdn.net/0hiLY1e8wXNmNkSB5jRl1JNFgNOA4TZjArHC1_BBZMOFYZf3A9W3kpBhZAOFMdK3k1DCkuVUJLawRP"
    }
    

Server Controller & Service & CLI講解

NestJS的CLI設計得相當完善,我們可以不用設定太多dirty thing就可以自動生產出一個NestJS Server。

首先我們安裝NestJS CLI,並創建project。

$ npm i -g @nestjs/cli
$ nest new project

安裝hbs作為網頁渲染的引擎

$ npm install --save hbs

main.ts設定hbs為網頁渲染引擎

// main.ts

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(
    AppModule,
  );

  app.useStaticAssets(join(__dirname, '..', 'public'));
  app.setBaseViewsDir(join(__dirname, '..', 'views'));
  app.setViewEngine('hbs');

  await app.listen(3000);
}
bootstrap();

安裝Config模組

$ npm i --save @nestjs/config

透過CLI直接創建auth Controller

$ nest g controller auth

設定app.module.ts,將Config、API、Auth Controller通通引入

// app.module.ts

import { Module, HttpModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthController } from './auth/auth.controller';
import { AuthService } from './auth/auth.service';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [HttpModule, ConfigModule.forRoot()],
  controllers: [AppController, AuthController],
  providers: [AppService, AuthService],
})
export class AppModule {}

Auth Controller講解,我註解在程式碼上

// auth.controller.ts

import { Get, Controller, Render, Query } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import * as qs from 'querystring';
import { AuthService } from './auth.service';

// login的前綴,也就是說,底下所有的Method的Route前面都會包含/login路徑
@Controller('login')
export class AuthController {
  constructor(private authService: AuthService, private configService: ConfigService){

  }
  // /login的route,會生成帶有button的網頁回傳
  @Get()
  @Render('index')
  root() {
    // 亂數生成state
    const state:string = crypto.randomBytes(43).toString('hex');
    // 亂數生成nonce
    const nonce:string = crypto.randomBytes(43).toString('hex');
    // 將response_type, client_id, redirect_uri, state, scope, nonce組成button的query參數,傳至index.hbs渲染給瀏覽器
    const query:string = qs.stringify({
      response_type: 'code',
      client_id: this.configService.get<string>('LINE_CLIENT_ID'),
      redirect_uri: `${this.configService.get<string>('SERVER_URI')}/login/auth`,
      state,
      scope: 'profile openid',
      nonce
    })
    return { lineAuthLoginURI: `${this.configService.get<string>('LINE_ACCESS_URI')}/oauth2/v2.1/authorize?${query}` };
  }

  @Get('/auth')
  @Render('auth')
  async auth(@Query('code') code) {
    try {
      // 拿到Line 認證網站的code之後,透過此code去Line API拿access token與OIDC ID Token
      const token = await this.authService.postToken(code).toPromise()
      return { token: JSON.stringify(token)}
    } catch (err) {
      console.log(err)
    }
  }
}
// auth.service.ts

// 引入HttpService模組,NestJS封裝的HTTP模組是透過Axios與RxJS整合的,擁有RxJS的方便的observable, observer, operator,可以組合各種非同步操作並利用operator管理資料流
import { Injectable, HttpService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { map } from 'rxjs/operators';
import * as qs from 'querystring';

@Injectable()
export class AuthService {
  constructor(private http: HttpService, private configService: ConfigService) {

  }

  postToken(code){
    // 將code以Post的方式送市Line API,並且也要攜帶client_id, client_secret等等參數,另外redirect_uri必須與Line Console上設定的相同此request才會成功
    return this.http.post(
      `${this.configService.get<string>('LINE_API_URI')}/oauth2/v2.1/token`,
      qs.stringify({
        grant_type: 'authorization_code',
        code,
        redirect_uri: `${this.configService.get<string>('SERVER_URI')}/login/auth`,
        client_id: this.configService.get<string>('LINE_CLIENT_ID'),
        client_secret: this.configService.get<string>('LINE_CLICLIENT_SECRET')
      }),
      {
        headers: {'Content-Type': 'application/x-www-form-urlencoded'}
      })
      .pipe(
        map(response => response.data)
      );        
  }
}

最後再把整體程式碼附上,怕大家有遺漏掉XD。


謝謝你的閱讀,也歡迎分享討論~最後再次附上家人為NestJS所畫的吉祥物圖XD。


尚未有邦友留言

立即登入留言