使用封鎖函式自訂驗證流程

本文說明如何使用封鎖 Cloud Run functions 擴充 Identity Platform 驗證。

封鎖函式可讓你執行自訂程式碼,藉此修改使用者註冊或登入應用程式的結果。舉例來說,如果使用者不符合特定條件,您可以禁止他們通過驗證,也可以在將使用者資訊傳回用戶端應用程式前更新資訊。

事前準備

使用 Identity Platform 建立應用程式。如要瞭解操作方式,請參閱快速入門導覽課程

瞭解封鎖函式

您可以為下列兩個事件註冊封鎖函式:

  • beforeCreate:在新使用者儲存至 Identity Platform 資料庫之前,以及權杖傳回至用戶端應用程式之前觸發。

  • beforeSignIn:在系統驗證使用者憑證後,但 Identity Platform 將 ID 權杖傳回用戶端應用程式前觸發。如果應用程式使用多重驗證,則會在使用者驗證第二個因素後觸發函式。請注意,除了 beforeCreate 之外,建立新使用者也會觸發 beforeSignIn

使用封鎖函式時,請注意下列事項:

  • 函式必須在 7 秒內回覆,7 秒後,Identity Platform 會傳回錯誤,用戶端作業也會失敗。

  • 系統會將 200 以外的 HTTP 回應碼傳遞至用戶端應用程式。請確保用戶端程式碼會處理函式可能傳回的任何錯誤。

  • 函式會套用至專案中的所有使用者,包括租戶中的使用者。Identity Platform 會向函式提供使用者相關資訊,包括使用者所屬的任何租戶,方便您做出適當回應。

  • 將其他身分識別提供者連結至帳戶會重新觸發所有已註冊的 beforeSignIn 函式。這不包括電子郵件和密碼供應商。

  • 匿名和自訂驗證不支援封鎖函式。

  • 如果您也使用非同步函式,非同步函式收到的使用者物件不會包含封鎖函式的更新。

建立封鎖函式

下列步驟說明如何建立封鎖函式:

  1. 前往Google Cloud 控制台的 Identity Platform「設定」頁面。

    前往「設定」頁面

  2. 選取「觸發程序」分頁標籤。

  3. 如要建立使用者註冊的封鎖函式,請選取「Before create (beforeCreate)」下方的「Function」下拉式選單,然後按一下「Create function」。如要建立使用者登入的封鎖函式,請在「登入前 (beforeSignIn)」下方建立函式。

  4. 建立新函式:

    1. 輸入函式名稱

    2. 在「Trigger」(觸發條件) 欄位中,選取 [HTTP]

    3. 在「Authentication」(驗證) 欄位中,選取「Allow unauthenticated invocations」(允許未經驗證的叫用)

    4. 點選「下一步」

  5. 使用內嵌編輯器開啟 index.js。刪除範例 helloWorld 程式碼,並替換成下列其中一個程式碼:

    如何回應註冊要求:

    import * as gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
    });
    

    如何回應登入要求:

    import * as gcipCloudFunctions from 'gcip-cloud-functions';
    
    const authClient = new gcipCloudFunctions.Auth();
    
    exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
    
    });
    
  6. 開啟 package.json 並新增下列依附元件區塊: 如需最新版 SDK,請參閱 gcip-cloud-functions

    {
      "type": "module",
      "name": ...,
      "version": ...,
    
      "dependencies": {
        "gcip-cloud-functions": "^0.2.0"
      }
    }
    
  7. 將函式的進入點設為 beforeSignIn

  8. 按一下「部署」發布函式。

  9. 在 Identity Platform 封鎖函式頁面中,按一下「儲存」

請參閱下列章節,瞭解如何實作函式。每次更新函式時,都必須重新部署。

您也可以使用 Google Cloud CLI 或 REST API 建立及管理函式。如要瞭解如何使用 Google Cloud CLI 部署函式,請參閱 Cloud Run functions 說明文件

取得使用者和內容資訊

beforeSignInbeforeCreate 事件會提供 UserEventContext 物件,其中包含登入使用者的相關資訊。您可以在程式碼中使用這些值,判斷是否允許作業繼續進行。

如要查看 User 物件可用的屬性清單,請參閱 UserRecord API 參考資料

EventContext 物件包含下列屬性:

名稱 說明 範例
locale 應用程式語言代碼。您可以使用用戶端 SDK 設定語言代碼,或在 REST API 中傳遞語言代碼標頭。 frsv-SE
ipAddress 使用者註冊或登入裝置的 IP 位址。 114.14.200.1
userAgent 觸發封鎖函式的使用者代理程式。 Mozilla/5.0 (X11; Linux x86_64)
eventId 活動的專屬 ID。 rWsyPtolplG2TBFoOkkgyg
eventType 事件類型。這項資訊會提供事件名稱,例如 beforeSignInbeforeCreate,以及使用的相關登入方法,例如 Google 或電子郵件/密碼。 providers/cloud.auth/eventTypes/user.beforeSignIn:password
authType 一律 USER USER
resource Identity Platform 專案或租戶。 projects/project-id/tenants/tenant-id
timestamp 事件觸發時間,格式為 RFC 3339 字串。 Tue, 23 Jul 2019 21:10:57 GMT
additionalUserInfo 包含使用者相關資訊的物件。 AdditionalUserInfo
credential 包含使用者憑證相關資訊的物件。 AuthCredential

禁止註冊或登入

如要封鎖註冊或登入嘗試,請在函式中擲回 HttpsError。例如:

Node.js

throw new gcipCloudFunctions.https.HttpsError('permission-denied');

下表列出您可以引發的錯誤,以及預設錯誤訊息:

名稱 程式碼 訊息
invalid-argument 400 用戶端指定的引數無效。
failed-precondition 400 無法在目前的系統狀態下執行要求。
out-of-range 400 用戶端指定的範圍無效。
unauthenticated 401 OAuth 權杖遺漏、無效或過期。
permission-denied 403 用戶端權限不足。
not-found 404 找不到您指定的資源。
aborted 409 發生並行衝突,例如讀取-修改-寫入衝突。
already-exists 409 用戶端嘗試建立的資源已存在。
resource-exhausted 429 資源配額用盡或達到頻率限制。
cancelled 499 用戶端已取消要求。
data-loss 500 發生無法復原的資料遺失或資料毀損情形。
unknown 500 發生不明的伺服器錯誤。
internal 500 內部伺服器錯誤。
not-implemented 501 伺服器未執行 API 方法。
unavailable 503 無法使用服務。
deadline-exceeded 504 已超出要求期限。

您也可以指定自訂錯誤訊息:

Node.js

throw new gcipCloudFunctions.https.HttpsError('permission-denied', 'Unauthorized request origin!');

以下範例說明如何封鎖特定網域以外的使用者註冊應用程式:

Node.js

// Import the Cloud Auth Admin module.
import * as gcipCloudFunctions from 'gcip-cloud-functions';
// Initialize the Auth client.
const authClient = new gcipCloudFunctions.Auth();
// Http trigger with Cloud Run functions.
exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  // If the user is authenticating within a tenant context, the tenant ID can be determined from
  // user.tenantId or from context.resource, eg. 'projects/project-id/tenant/tenant-id-1'
  // Only users of a specific domain can sign up.
  if (!user.email.endsWith('@acme.com')) {
    throw new gcipCloudFunctions.https.HttpsError('invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

無論您使用預設或自訂訊息,Cloud Run 函式都會包裝錯誤,並以內部錯誤的形式傳回給用戶端。舉例來說,如果您在函式中引發下列錯誤:

throw new gcipCloudFunctions.https.HttpsError('invalid-argument', `Unauthorized email user@evil.com}`);

系統會將類似下列內容的錯誤傳回給用戶端應用程式 (如果您使用用戶端 SDK,錯誤會包裝為內部錯誤):

{
  "error": {
    "code": 400,
    "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error. Code: 400, Status: \"INVALID_ARGUMENT\", Message: \"Unauthorized email user@evil.com\"",
    "errors": [
      {
        "message": "BLOCKING_FUNCTION_ERROR_RESPONSE : HTTP Cloud Function returned an error. Code: 400, Status: \"INVALID_ARGUMENT\", Message: \"Unauthorized email user@evil.com\"",
        "domain": "global",
        "reason": "invalid"
      }
    ]
  }
}

應用程式應擷取錯誤,並視情況處理。例如:

JavaScript

// Blocking functions can also be triggered in a multi-tenant context before user creation.
// firebase.auth().tenantId = 'tenant-id-1';
firebase.auth().createUserWithEmailAndPassword('johndoe@example.com', 'password')
  .then((result) => {
    result.user.getIdTokenResult()
  })
  .then((idTokenResult) => {
    console.log(idTokenResult.claim.admin);
  })
  .catch((error) => {
    if (error.code !== 'auth/internal-error' && error.message.indexOf('Cloud Function') !== -1) {
      // Display error.
    } else {
      // Registration succeeds.
    }
  });

修改使用者

您可以允許作業繼續進行,但修改儲存至 Identity Platform 資料庫並傳回給用戶端的 User 物件,而非封鎖註冊或登入嘗試。

如要修改使用者,請從事件處理常式傳回包含要修改欄位的物件。您可以修改下列欄位:

  • displayName
  • disabled
  • emailVerified
  • photoURL
  • customClaims
  • sessionClaims (僅限 beforeSignIn)

除了 sessionClaims 以外,所有修改過的欄位都會儲存到 Identity Platform 的資料庫,也就是說,這些欄位會納入回應權杖,並在使用者工作階段之間保留。

以下範例說明如何設定預設顯示名稱:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  return {
    // If no display name is provided, set it to "guest".
    displayName: user.displayName || 'guest'
  };
});

如果您同時為 beforeCreatebeforeSignIn 註冊事件處理常式,請注意 beforeSignIn 會在 beforeCreate 之後執行。在 beforeCreate 中更新的使用者欄位會顯示在 beforeSignIn 中。如果您在兩個事件處理常式中設定 sessionClaims 以外的欄位,則 beforeSignIn 中設定的值會覆寫 beforeCreate 中設定的值。如果是 sessionClaims,系統只會將這些屬性傳播至目前工作階段的權杖聲明,但不會保留或儲存在資料庫中。

舉例來說,如果設定了任何 sessionClaimsbeforeSignIn 會連同任何 beforeCreate 聲明一併傳回,並合併這些聲明。合併時,如果 sessionClaims 鍵與 customClaims 中的鍵相符,則權杖聲明中的相符 customClaims 會由 sessionClaims 鍵覆寫。不過,覆寫的 customClaims 金鑰仍會保留在資料庫中,以供日後要求使用。

支援的 OAuth 憑證和資料

您可以將 OAuth 憑證和資料從各種身分識別提供者傳遞至封鎖函式。下表列出各身分識別提供者支援的憑證和資料:

識別資訊提供者 ID 權杖 存取權杖 到期時間 權杖密鑰 更新權杖 登入聲明
Google
Facebook
Twitter
GitHub
Microsoft
LinkedIn
Yahoo
Apple
SAML
OIDC

更新權杖

如要在封鎖函式中使用重新整理權杖,您必須先在 Google Cloud 控制台的「Include token credentials」(包含權杖憑證)下拉式選單中,選取「Triggers」(觸發條件)部分的核取方塊。

如果直接使用 OAuth 憑證 (例如 ID 權杖或存取權杖) 登入,任何身分識別提供者都不會傳回更新權杖。在這種情況下,系統會將相同的用戶端 OAuth 憑證傳遞至封鎖函式。不過,如果是 3 腿流程,如果身分識別提供者支援,或許可以使用重新整理權杖。

以下各節將說明各身分識別提供者類型,以及支援的憑證和資料。

一般 OIDC 提供者

使用者透過一般 OIDC 提供者登入時,系統會傳遞下列憑證:

  • ID 權杖:如果選取 id_token 流程,系統會提供這個權杖。
  • 存取權杖:如果選取程式碼流程,系統就會提供存取權杖。請注意,程式碼流程僅支援 REST API。
  • 刷新權杖:如果選取 offline_access 範圍,系統會提供這項權杖。

範例:

const provider = new firebase.auth.OAuthProvider('oidc.my-provider');
provider.addScope('offline_access');
firebase.auth().signInWithPopup(provider);

Google

使用者透過 Google 登入時,系統會傳遞下列憑證:

  • ID 權杖
  • 存取權杖
  • 重新整理權杖:只有在要求下列自訂參數時才會提供:
    • access_type=offline
    • prompt=consent:如果使用者先前已同意,且未要求任何新範圍

範例:

const provider = new firebase.auth.GoogleAuthProvider();
provider.setCustomParameters({
  'access_type': 'offline',
  'prompt': 'consent'
});
firebase.auth().signInWithPopup(provider);

進一步瞭解 Google 重新整理權杖

Facebook

使用者透過 Facebook 登入時,系統會傳遞下列憑證:

  • 存取權杖:系統會傳回存取權杖,可換取另一個存取權杖。進一步瞭解 Facebook 支援的不同類型存取權杖,以及如何將這些權杖換成長期有效權杖

GitHub

使用者透過 GitHub 登入時,系統會傳遞下列憑證:

  • 存取權杖:除非撤銷,否則不會過期。

Microsoft

使用者透過 Microsoft 登入時,系統會傳遞下列憑證:

  • ID 權杖
  • 存取權杖
  • 重新整理權杖:如果選取 offline_access 範圍,系統會將這個權杖傳遞至封鎖函式。

範例:

const provider = new firebase.auth.OAuthProvider('microsoft.com');
provider.addScope('offline_access');
firebase.auth().signInWithPopup(provider);

Yahoo

使用者透過 Yahoo 登入時,系統會傳遞下列憑證,不含任何自訂參數或範圍:

  • ID 權杖
  • 存取權杖
  • 更新權杖

LinkedIn

使用者透過 LinkedIn 登入時,系統會傳遞下列憑證:

  • 存取權杖

Apple

使用者透過 Apple 登入時,系統會傳遞下列憑證,不含任何自訂參數或範圍:

  • ID 權杖
  • 存取權杖
  • 更新權杖

常見情境

下列範例為一些封鎖函式的使用案例:

允許特定網域註冊

以下範例說明如何防止不屬於 example.com 網域的使用者向應用程式註冊:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (!user.email || user.email.indexOf('@example.com') === -1) {
    throw new gcipCloudFunctions.https.HttpsError(
      'invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

禁止電子郵件未經驗證的使用者註冊

以下範例說明如何禁止電子郵件未經驗證的使用者註冊應用程式:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (user.email && !user.emailVerified) {
    throw new gcipCloudFunctions.https.HttpsError(
      'invalid-argument', `Unverified email "${user.email}"`);
  }
});

註冊時必須進行電子郵件驗證

以下範例說明如何要求使用者在註冊後驗證電子郵件地址:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  const locale = context.locale;
  if (user.email && !user.emailVerified) {
    // Send custom email verification on sign-up.
    return admin.auth().generateEmailVerificationLink(user.email).then((link) => {
      return sendCustomVerificationEmail(user.email, link, locale);
    });
  }
});

exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
 if (user.email && !user.emailVerified) {
   throw new gcipCloudFunctions.https.HttpsError(
     'invalid-argument', `"${user.email}" needs to be verified before access is granted.`);
  }
});

將特定身分識別提供者的電子郵件視為已驗證

以下範例說明如何將特定身分識別提供者的使用者電子郵件視為已驗證:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (user.email && !user.emailVerified && context.eventType.indexOf(':facebook.com') !== -1) {
    return {
      emailVerified: true,
    };
  }
});

封鎖來自特定 IP 位址的登入要求

以下範例說明如何封鎖特定 IP 位址範圍的登入要求:

Node.js

exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
  if (isSuspiciousIpAddress(context.ipAddress)) {
    throw new gcipCloudFunctions.https.HttpsError(
      'permission-denied', 'Unauthorized access!');
  }
});

設定自訂和工作階段聲明

以下範例說明如何設定自訂和工作階段聲明:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'saml.my-provider-id') {
    return {
      // Employee ID does not change so save in persistent claims (stored in
      // Auth DB).
      customClaims: {
        eid: context.credential.claims.employeeid,
      },
      // Copy role and groups to token claims. These will not be persisted.
      sessionClaims: {
        role: context.credential.claims.role,
        groups: context.credential.claims.groups,
      }
    }
  }
});

追蹤 IP 位址,監控可疑活動

您可以追蹤使用者登入時的 IP 位址,並與後續要求中的 IP 位址進行比較,防止權杖遭竊。如果要求看起來可疑 (例如 IP 位址來自不同地理區域),您可以要求使用者重新登入。

  1. 使用工作階段聲明追蹤使用者登入時所用的 IP 位址:

    Node.js

    exports.beforeSignIn = authClient.functions().beforeSignInHandler((user, context) => {
      return {
        sessionClaims: {
          signInIpAddress: context.ipAddress,
        },
      };
    });
    
  2. 當使用者嘗試存取需要透過 Identity Platform 驗證的資源時,請比較要求中的 IP 位址與登入時使用的 IP:

    Node.js

    app.post('/getRestrictedData', (req, res) => {
      // Get the ID token passed.
      const idToken = req.body.idToken;
      // Verify the ID token, check if revoked and decode its payload.
      admin.auth().verifyIdToken(idToken, true).then((claims) => {
        // Get request IP address
        const requestIpAddress = req.connection.remoteAddress;
        // Get sign-in IP address.
        const signInIpAddress = claims.signInIpAddress;
        // Check if the request IP address origin is suspicious relative to
        // the session IP addresses. The current request timestamp and the
        // auth_time of the ID token can provide additional signals of abuse,
        // especially if the IP address suddenly changed. If there was a sudden
        // geographical change in a short period of time, then it will give
        // stronger signals of possible abuse.
        if (!isSuspiciousIpAddressChange(signInIpAddress, requestIpAddress)) {
          // Suspicious IP address change. Require re-authentication.
          // You can also revoke all user sessions by calling:
          // admin.auth().revokeRefreshTokens(claims.sub).
          res.status(401).send({error: 'Unauthorized access. Please login again!'});
        } else {
          // Access is valid. Try to return data.
          getData(claims).then(data => {
            res.end(JSON.stringify(data);
          }, error => {
            res.status(500).send({ error: 'Server error!' })
          });
        }
      });
    });
    

螢幕使用者相片

以下範例說明如何清除使用者個人資料相片:

Node.js

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (user.photoURL) {
    return isPhotoAppropriate(user.photoURL)
      .then((status) => {
        if (!status) {
          // Sanitize inappropriate photos by replacing them with guest photos.
          // Users could also be blocked from sign-up, disabled, etc.
          return {
            photoURL: PLACEHOLDER_GUEST_PHOTO_URL,
          };
        }
      });
});

如要進一步瞭解如何偵測及清除圖片,請參閱 Cloud Vision 說明文件。

存取使用者的身分識別提供者 OAuth 憑證

以下範例示範如何取得透過 Google 登入的使用者重新整理權杖,並使用該權杖呼叫 Google Calendar API。系統會儲存重新整理權杖,以供離線存取。

Node.js

const {OAuth2Client} = require('google-auth-library');
const {google} = require('googleapis');
const gcipCloudFunctions = require('gcip-cloud-functions');

// ...
// Initialize Google OAuth client.
const keys = require('./oauth2.keys.json');
const oAuth2Client = new OAuth2Client(
  keys.web.client_id,
  keys.web.client_secret
);

exports.beforeCreate = authClient.functions().beforeCreateHandler((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'google.com') {
    // Store the refresh token for later offline use.
    // These will only be returned if refresh tokens credentials are included
    // (enabled by Cloud console).
    return saveUserRefreshToken(
        user.uid,
        context.credential.refreshToken,
        'google.com'
      )
      .then(() => {
        // Blocking the function is not required. The function can resolve while
        // this operation continues to run in the background.
        return new Promise((resolve, reject) => {
          // For this operation to succeed, the appropriate OAuth scope should be requested
          // on sign in with Google, client-side. In this case:
          // https://www.googleapis.com/auth/calendar
          // You can check granted_scopes from within:
          // context.additionalUserInfo.profile.granted_scopes (space joined list of scopes).

          // Set access token/refresh token.
          oAuth2Client.setCredentials({
            access_token: context.credential.accessToken,
            refresh_token: context.credential.refreshToken,
          });
          const calendar = google.calendar('v3');
          // Setup Onboarding event on user's calendar.
          const event = {/** ... */};
          calendar.events.insert({
            auth: oauth2client,
            calendarId: 'primary',
            resource: event,
          }, (err, event) => {
            // Do not fail. This is a best effort approach.
            resolve();
          });
      });
    })
  }
});

後續步驟