Cloud Functions 차단으로 Firebase 인증 확장


차단 함수를 사용하면 사용자가 앱에 등록하거나 로그인한 결과를 수정하는 커스텀 코드를 실행할 수 있습니다. 예를 들어 사용자가 특정 기준을 충족하지 않는 경우 인증하지 못하도록 하거나 클라이언트 앱으로 반환하기 전에 사용자 정보를 업데이트할 수 있습니다.

시작하기 전에

차단 함수를 사용하려면 Identity Platform을 사용하여 Firebase 프로젝트를 Firebase 인증으로 업그레이드해야 합니다. 아직 업그레이드하지 않았다면 먼저 업그레이드하세요.

차단 함수 이해

다음과 같은 두 가지 이벤트에 차단 함수를 등록할 수 있습니다.

  • beforeCreate: 신규 사용자가 Firebase 인증 데이터베이스에 저장되기 전 그리고 클라이언트 앱에 토큰이 반환되기 전에 트리거됩니다.

  • beforeSignIn: 사용자 인증 정보가 확인된 후 Firebase 인증에서 클라이언트 앱에 ID 토큰을 반환하기 전에 트리거됩니다. 앱에서 다중 인증(MFA)을 사용하는 경우 사용자가 2단계 인증을 완료하면 함수가 트리거됩니다. 신규 사용자를 만들면 beforeCreate 이외에 beforeSignIn도 트리거됩니다.

차단 함수를 사용할 때는 다음 사항에 유의하세요.

  • 함수는 7초 이내에 응답해야 합니다. 7초 후에 Firebase 인증이 오류를 반환하고 클라이언트 작업이 실패합니다.

  • 200 이외의 HTTP 응답 코드가 클라이언트 앱으로 전달됩니다. 함수가 반환할 수 있는 오류를 클라이언트 코드가 처리하는지 확인합니다.

  • 함수는 테넌트에 포함된 사용자를 포함하여 프로젝트의 모든 사용자에게 적용됩니다. Firebase 인증은 사용자가 속한 테넌트를 비롯하여 사용자에 대한 정보를 함수에 제공하므로 그에 따라 응답할 수 있습니다.

  • 다른 ID 공급업체를 계정에 연결하면 등록된 beforeSignIn 함수가 다시 트리거됩니다.

  • 익명 및 커스텀 인증은 차단 함수를 트리거하지 않습니다.

차단 함수 배포 및 등록

사용자 인증 흐름에 커스텀 코드를 삽입하려면 차단 함수를 배포하고 등록합니다. 차단 함수가 배포되고 등록되면 인증 및 사용자를 생성하기 위해 커스텀 코드가 성공적으로 완료되어야 합니다.

차단 함수 배포

함수를 배포하는 것과 동일한 방식으로 차단 함수를 배포합니다. 자세한 내용은 Cloud Functions 시작하기 페이지를 참조하세요. 요약하면 다음과 같습니다.

  1. beforeCreate 이벤트, beforeSignIn 이벤트 또는 둘 다를 처리하는 Cloud Functions를 작성합니다.

    예를 들어 index.js에 다음과 같은 노옵스(no-ops) 함수를 추가하여 시작할 수 있습니다.

    const functions = require('firebase-functions');
    
    exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
      // TODO
    });
    
    exports.beforeSignIn = functions.auth.user().beforeSignIn((user, context) => {
      // TODO
    });
    

    위 예시에서는 커스텀 인증 로직 구현이 생략되었습니다. 특정 예에 대한 차단 함수 및 일반적인 시나리오를 구현하는 방법을 알아보려면 다음 섹션을 참조하세요.

  2. Firebase CLI를 사용한 함수 배포:

    firebase deploy --only functions
    

    함수를 업데이트할 때마다 다시 배포해야 합니다.

차단 함수 등록

  1. Firebase Console에서 Firebase 인증 설정 페이지로 이동합니다.

  2. 차단 함수 탭을 선택합니다.

  3. 계정 생성 전(beforeCreate) 또는 로그인 전(beforeSignIn)의 드롭다운 메뉴에서 차단 함수를 선택하여 등록합니다.

  4. 변경사항을 저장합니다.

사용자 및 컨텍스트 정보 가져오기

beforeSignInbeforeCreate 이벤트는 사용자 로그인에 대한 정보가 포함된 UserEventContext 객체를 제공합니다. 작업 진행을 허용할지 여부를 결정하기 위해 이러한 값을 코드에서 사용합니다.

User 객체에서 사용할 수 있는 속성 목록은 UserRecord API 참조를 확인하세요.

EventContext 객체에는 다음 속성이 포함되어 있습니다.

이름 설명
locale 애플리케이션 언어. 클라이언트 SDK를 사용하거나 REST API에서 언어 헤더를 전달하여 언어를 설정할 수 있습니다. fr 또는 sv-SE
ipAddress 최종 사용자가 등록하거나 로그인한 기기의 IP 주소입니다. 114.14.200.1
userAgent 차단 함수를 트리거하는 사용자 에이전트입니다. Mozilla/5.0 (X11; Linux x86_64)
eventId 이벤트의 고유 식별자입니다. rWsyPtolplG2TBFoOkkgyg
eventType 이벤트 유형입니다. beforeSignIn 또는 beforeCreate와 같은 이벤트 이름, 그리고 Google이나 이메일/비밀번호 등 사용된 로그인 방법을 제공합니다. providers/cloud.auth/eventTypes/user.beforeSignIn:password
authType 항상 USER입니다. USER
resource Firebase 인증 프로젝트 또는 테넌트입니다. 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 functions.auth.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 functions.auth.HttpsError('permission-denied', 'Unauthorized request origin!');

다음 예시에서는 특정 도메인 내에 있지 않은 사용자가 앱에 등록하는 것을 차단하는 방법을 보여줍니다.

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((user, context) => {
  // (If the user is authenticating within a tenant context, the tenant ID can be determined from
  // user.tenantId or from context.resource, e.g. 'projects/project-id/tenant/tenant-id-1')

  // Only users of a specific domain can sign up.
  if (user.email.indexOf('@acme.com') === -1) {
    throw new functions.auth.HttpsError('invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

기본 또는 커스텀 메시지 사용 여부에 관계없이 Cloud Functions는 오류를 래핑하고 내부 오류로 클라이언트에게 반환합니다. 예를 들면 다음과 같습니다.

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

앱이 오류를 포착하여 적절히 처리해야 합니다. 예를 들면 다음과 같습니다.

자바스크립트

// 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.
    }
  });

사용자 수정

등록 또는 로그인 시도를 차단하는 대신 작업을 계속 허용할 수 있으나 Firebase 인증 데이터베이스에 저장되고 클라이언트에 반환되는 User 객체를 수정할 수 있습니다.

사용자를 수정하려면 수정할 필드가 포함된 이벤트 핸들러에서 객체를 반환합니다. 다음 필드를 수정할 수 있습니다.

  • displayName
  • disabled
  • emailVerified
  • photoUrl
  • customClaims
  • sessionClaims(beforeSignIn만)

sessionClaims를 제외하고 모든 수정된 필드는 Firebase 인증의 데이터베이스에 저장됩니다. 즉, 해당 필드는 응답 토큰에 포함되며 사용자 세션 간에 유지됩니다.

다음 예시에서는 기본 표시 이름을 설정하는 방법을 보여줍니다.

Node.js

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

beforeCreatebeforeSignIn 모두에 이벤트 핸들러를 등록하면 beforeCreate가 실행된 후 beforeSignIn이 실행됩니다. beforeCreate에서 업데이트된 사용자 필드는 beforeSignIn에 표시됩니다. 두 이벤트 핸들러에서 sessionClaims 이외의 필드를 설정하면 beforeSignIn에 설정된 값이 beforeCreate에 설정된 값을 덮어씁니다. sessionClaims의 경우에만 현재 세션의 토큰 클레임에 전파되지만 데이터베이스에 유지되거나 저장되지는 않습니다.

예를 들어 sessionClaims가 설정된 경우 beforeSignInbeforeCreate 클레임과 함께 이를 반환하고 병합합니다. 병합되면 sessionClaims 키가 customClaims의 키와 일치하면 일치하는 customClaimssessionClaims 키에 의해 토큰 클레임에서 덮어쓰기됩니다. 그러나 향후 요청 시 덮어쓰인 customClaims 키가 데이터베이스에서 계속 유지됩니다.

지원되는 OAuth 사용자 인증 정보 및 데이터

OAuth 사용자 인증 정보와 데이터를 여러 ID 공급업체의 차단 함수에 전달할 수 있습니다. 다음 표에서는 각 ID 공급업체에서 지원되는 사용자 인증 정보와 데이터를 보여줍니다.

ID 공급업체 ID 토큰 액세스 토큰 만료 시간 토큰 보안 비밀 갱신 토큰 로그인 클레임
Google 아니요 아니요
Facebook 아니요 아니요 아니요 아니요
Twitter 아니요 아니요 아니요 아니요
GitHub 아니요 아니요 아니요 아니요 아니요
Microsoft 아니요 아니요
LinkedIn 아니요 아니요 아니요 아니요
Yahoo 아니요 아니요
Apple 아니요 아니요
SAML 아니요 아니요 아니요 아니요 아니요
OIDC 아니요

갱신 토큰

차단 함수에서 갱신 토큰을 사용하려면 먼저 Firebase Console의 차단 함수 페이지에서 체크박스를 선택해야 합니다.

ID 토큰이나 액세스 토큰과 같은 OAuth 사용자 인증 정보를 사용하여 직접 로그인하면 갱신 토큰은 ID 공급업체에 의해 반환되지 않습니다. 이 경우 동일한 클라이언트 측 OAuth 사용자 인증 정보가 차단 함수로 전달됩니다.

다음 섹션에서는 각 ID 공급업체 유형과 지원되는 사용자 인증 정보 및 데이터를 설명합니다.

일반 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 = functions.auth.user().beforeCreate((user, context) => {
  if (!user.email || user.email.indexOf('@example.com') === -1) {
    throw new functions.auth.HttpsError(
      'invalid-argument', `Unauthorized email "${user.email}"`);
  }
});

이메일이 확인되지 않은 사용자의 등록 차단

다음 예시는 이메일이 확인되지 않은 사용자가 앱에 등록하는 것을 방지하는 방법을 보여줍니다.

Node.js

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

등록 시 이메일 인증 필요

다음 예시에서는 등록 후 사용자의 이메일 확인을 요구하는 방법을 보여줍니다.

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((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 = functions.auth.user().beforeSignIn((user, context) => {
 if (user.email && !user.emailVerified) {
   throw new functions.auth.HttpsError(
     'invalid-argument', `"${user.email}" needs to be verified before access is granted.`);
  }
});

특정 ID 공급업체 이메일을 확인된 것으로 처리

다음 예시에서는 특정 ID 공급업체의 사용자 이메일을 확인된 것으로 처리하는 방법을 보여줍니다.

Node.js

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

특정 IP 주소에서의 로그인 차단

다음 예시에서는 특정 IP 주소 범위에서의 로그인을 차단하는 방법을 보여줍니다.

Node.js

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

커스텀 및 세션 클레임 설정

다음 예시에서는 커스텀 및 세션 클레임을 설정하는 방법을 보여줍니다.

Node.js

exports.beforeCreate = functions.auth.user().beforeCreate((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 = functions.auth.user().beforeSignIn((user, context) => {
      return {
        sessionClaims: {
          signInIpAddress: context.ipAddress,
        },
      };
    });
    
  2. 사용자가 Firebase 인증이 필요한 리소스에 액세스하려고 시도하면 요청의 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 = functions.auth.user().beforeCreate((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 문서를 참조하세요.

사용자의 ID 공급업체 OAuth 사용자 인증 정보 액세스

다음 예시에서는 Google 계정으로 로그인한 사용자의 갱신 토큰을 가져와서 Google Calendar API를 호출하는 방법을 보여줍니다. 갱신 토큰은 오프라인 액세스를 위해 저장됩니다.

Node.js

const {OAuth2Client} = require('google-auth-library');
const {google} = require('googleapis');
// ...
// Initialize Google OAuth client.
const keys = require('./oauth2.keys.json');
const oAuth2Client = new OAuth2Client(
  keys.web.client_id,
  keys.web.client_secret
);

exports.beforeCreate = functions.auth.user().beforeCreate((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();
          });
      });
    })
  }
});