使用阻斷函式自訂驗證流程

本文說明如何使用阻斷式 Cloud Run 函式擴充 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」「Settings」(設定) 頁面。

    前往「設定」頁面

  2. 選取「觸發事件」分頁標籤。

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

  4. 建立新函式:

    1. 輸入函式的名稱

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

    3. 在「Authentication」欄位中,選取「Allow unauthenticated invocations」

    4. 點按「Next」

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

    如何回覆註冊要求:

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

    如何回應登入要求:

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

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

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

  9. 在 Identity Platform 封鎖函式頁面上,按一下「Save」

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

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

取得使用者和內容資訊

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 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 [email protected]}`);

系統會傳回類似下列的錯誤給用戶端應用程式 (如果您使用用戶端 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 [email protected]\"",
        "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('[email protected]', '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 會將這些 sessionClaims 與任何 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 日曆 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();
          });
      });
    })
  }
});

後續步驟