管理用户会话

Firebase Authentication 会话属于长期会话。每次用户登录时,系统都会将用户凭据发送到 Firebase Authentication 后端,用以交换 Firebase ID 令牌 (JWT) 和刷新令牌。Firebase ID 令牌属于短期令牌,有效期只有一个小时;刷新令牌可用来检索新的 ID 令牌。 只有出现以下某种情况时,刷新令牌才会过期:

  • 用户被删除
  • 用户被停用
  • 检测到用户账号出现重大变化,例如密码或电子邮件地址更新等事件。

通过 Firebase Admin SDK,您可以撤消指定用户的刷新令牌。此外,我们还提供了一个用来检查 ID 令牌撤消情况的 API。借助这些功能,您可以更好地控制用户会话。该 SDK 还允许您添加限制,防止会话被用于可疑用途,并提供了恢复机制来应对可能的令牌盗用问题。

撤消刷新令牌

当用户上报设备丢失或被盗时,您可以撤消该用户现有的刷新令牌。同样,如果您发现普遍性的漏洞或怀疑有大量有效令牌泄露,可以使用 listUsers API 来查找指定项目的所有用户并撤消他们的令牌。

重设密码也会撤消用户的现有令牌;但在这种情况下,Firebase Authentication 后端会自动处理此类撤消。 撤消令牌后,系统会将用户退出登录并提示用户重新进行身份验证。

以下是使用 Admin SDK 撤消某个指定用户的刷新令牌的代码示例。如需初始化 Admin SDK,请按照设置页面上的说明进行操作。

Node.js

// Revoke all refresh tokens for a specified user for whatever reason.
// Retrieve the timestamp of the revocation, in seconds since the epoch.
getAuth()
  .revokeRefreshTokens(uid)
  .then(() => {
    return getAuth().getUser(uid);
  })
  .then((userRecord) => {
    return new Date(userRecord.tokensValidAfterTime).getTime() / 1000;
  })
  .then((timestamp) => {
    console.log(`Tokens revoked at: ${timestamp}`);
  });

Java

FirebaseAuth.getInstance().revokeRefreshTokens(uid);
UserRecord user = FirebaseAuth.getInstance().getUser(uid);
// Convert to seconds as the auth_time in the token claims is in seconds too.
long revocationSecond = user.getTokensValidAfterTimestamp() / 1000;
System.out.println("Tokens revoked at: " + revocationSecond);

Python

# Revoke tokens on the backend.
auth.revoke_refresh_tokens(uid)
user = auth.get_user(uid)
# Convert to seconds as the auth_time in the token claims is in seconds.
revocation_second = user.tokens_valid_after_timestamp / 1000
print('Tokens revoked at: {0}'.format(revocation_second))

Go

client, err := app.Auth(ctx)
if err != nil {
	log.Fatalf("error getting Auth client: %v\n", err)
}
if err := client.RevokeRefreshTokens(ctx, uid); err != nil {
	log.Fatalf("error revoking tokens for user: %v, %v\n", uid, err)
}
// accessing the user's TokenValidAfter
u, err := client.GetUser(ctx, uid)
if err != nil {
	log.Fatalf("error getting user %s: %v\n", uid, err)
}
timestamp := u.TokensValidAfterMillis / 1000
log.Printf("the refresh tokens were revoked at: %d (UTC seconds) ", timestamp)

C#

await FirebaseAuth.DefaultInstance.RevokeRefreshTokensAsync(uid);
var user = await FirebaseAuth.DefaultInstance.GetUserAsync(uid);
Console.WriteLine("Tokens revoked at: " + user.TokensValidAfterTimestamp);

检测 ID 令牌撤消

由于 Firebase ID 令牌是无状态的 JWT,您只能通过从 Firebase Authentication 后端请求令牌的状态来确定该令牌是否已撤消。因此,在服务器上执行此类检查是一项代价很高的操作,需要额外的网络往返。您可以设置用于检查撤消的 Firebase Security Rules,而不使用 Admin SDK 进行此类检查,从而避免发出上述网络请求。

Firebase Security Rules 中检测 ID 令牌撤消

为了能够使用安全规则来检测 ID 令牌撤消情况,我们必须首先存储一些专门针对特定用户的元数据。

Firebase Realtime Database 中更新专门针对特定用户的元数据。

保存刷新令牌的撤消时间戳。在通过 Firebase Security Rules来跟踪 ID 令牌的撤消情况时,需要用到此时间戳。有了该时间戳,您便可在数据库内进行高效的检查。 在下面的代码示例中,使用在上一部分中获得的 UID 和撤消时间。

Node.js

const metadataRef = getDatabase().ref('metadata/' + uid);
metadataRef.set({ revokeTime: utcRevocationTimeSecs }).then(() => {
  console.log('Database updated successfully.');
});

Java

DatabaseReference ref = FirebaseDatabase.getInstance().getReference("metadata/" + uid);
Map<String, Object> userData = new HashMap<>();
userData.put("revokeTime", revocationSecond);
ref.setValueAsync(userData);

Python

metadata_ref = firebase_admin.db.reference("metadata/" + uid)
metadata_ref.set({'revokeTime': revocation_second})

Firebase Security Rules中添加检查

如需执行此检查,请设置一条没有客户端写入权限的规则,按用户来存储撤消时间。如上文示例所示,您可以使用上次撤消的 UTC 时间戳进行更新:

{
  "rules": {
    "metadata": {
      "$user_id": {
        // this could be false as it is only accessed from backend or rules.
        ".read": "$user_id === auth.uid",
        ".write": "false",
      }
    }
  }
}

任何需要通过身份验证才能访问的数据都必须配置以下规则。此逻辑仅允许持有未撤消的 ID 令牌且通过身份验证的用户访问受保护的数据:

{
  "rules": {
    "users": {
      "$user_id": {
        ".read": "auth != null && $user_id === auth.uid && (
            !root.child('metadata').child(auth.uid).child('revokeTime').exists()
          || auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()
        )",
        ".write": "auth != null && $user_id === auth.uid && (
            !root.child('metadata').child(auth.uid).child('revokeTime').exists()
          || auth.token.auth_time > root.child('metadata').child(auth.uid).child('revokeTime').val()
        )",
      }
    }
  }
}

通过 SDK 检测 ID 令牌撤消。

在您的服务器中,实现以下逻辑以检查刷新令牌是否已撤消以及验证 ID 令牌:

验证用户的 ID 令牌时,必须向 verifyIdToken 额外传递一个 checkRevoked 布尔值标志。如果用户的令牌已撤消,应在客户端将用户退出登录,或要求其使用 Firebase Authentication 客户端 SDK 提供的重新验证 API 重新进行身份验证。

如需针对您的平台初始化 Admin SDK,请按照设置页面上的说明进行操作。verifyIdToken 部分中给出了检索 ID 令牌的几个示例。

Node.js

// Verify the ID token while checking if the token is revoked by passing
// checkRevoked true.
let checkRevoked = true;
getAuth()
  .verifyIdToken(idToken, checkRevoked)
  .then((payload) => {
    // Token is valid.
  })
  .catch((error) => {
    if (error.code == 'auth/id-token-revoked') {
      // Token has been revoked. Inform the user to reauthenticate or signOut() the user.
    } else {
      // Token is invalid.
    }
  });

Java

try {
  // Verify the ID token while checking if the token is revoked by passing checkRevoked
  // as true.
  boolean checkRevoked = true;
  FirebaseToken decodedToken = FirebaseAuth.getInstance()
      .verifyIdToken(idToken, checkRevoked);
  // Token is valid and not revoked.
  String uid = decodedToken.getUid();
} catch (FirebaseAuthException e) {
  if (e.getAuthErrorCode() == AuthErrorCode.REVOKED_ID_TOKEN) {
    // Token has been revoked. Inform the user to re-authenticate or signOut() the user.
  } else {
    // Token is invalid.
  }
}

Python

try:
    # Verify the ID token while checking if the token is revoked by
    # passing check_revoked=True.
    decoded_token = auth.verify_id_token(id_token, check_revoked=True)
    # Token is valid and not revoked.
    uid = decoded_token['uid']
except auth.RevokedIdTokenError:
    # Token revoked, inform the user to reauthenticate or signOut().
    pass
except auth.UserDisabledError:
    # Token belongs to a disabled user record.
    pass
except auth.InvalidIdTokenError:
    # Token is invalid
    pass

Go

client, err := app.Auth(ctx)
if err != nil {
	log.Fatalf("error getting Auth client: %v\n", err)
}
token, err := client.VerifyIDTokenAndCheckRevoked(ctx, idToken)
if err != nil {
	if err.Error() == "ID token has been revoked" {
		// Token is revoked. Inform the user to reauthenticate or signOut() the user.
	} else {
		// Token is invalid
	}
}
log.Printf("Verified ID token: %v\n", token)

C#

try
{
    // Verify the ID token while checking if the token is revoked by passing checkRevoked
    // as true.
    bool checkRevoked = true;
    var decodedToken = await FirebaseAuth.DefaultInstance.VerifyIdTokenAsync(
        idToken, checkRevoked);
    // Token is valid and not revoked.
    string uid = decodedToken.Uid;
}
catch (FirebaseAuthException ex)
{
    if (ex.AuthErrorCode == AuthErrorCode.RevokedIdToken)
    {
        // Token has been revoked. Inform the user to re-authenticate or signOut() the user.
    }
    else
    {
        // Token is invalid.
    }
}

在客户端上响应令牌撤消

如果令牌已通过 Admin SDK 撤消,客户端会收到撤消通知,并且会要求用户重新进行身份验证或将其退出登录:

function onIdTokenRevocation() {
  // For an email/password user. Prompt the user for the password again.
  let password = prompt('Please provide your password for reauthentication');
  let credential = firebase.auth.EmailAuthProvider.credential(
      firebase.auth().currentUser.email, password);
  firebase.auth().currentUser.reauthenticateWithCredential(credential)
    .then(result => {
      // User successfully reauthenticated. New ID tokens should be valid.
    })
    .catch(error => {
      // An error occurred.
    });
}

高级安全措施:实施 IP 地址限制

跟踪请求 IP 地址的来源是一种检测令牌失窃的常见安全机制。例如,如果请求始终来自同一 IP 地址(发出请求的服务器),则可强制实行单一 IP 地址会话。或者,如果您检测到用户 IP 地址所在地理区域突然改变,或者收到来自可疑来源的请求,则可以撤消相关用户的令牌。

为了基于 IP 地址执行安全检查,请针对每一次身份验证请求检查 ID 令牌,并检查发出请求的 IP 地址是否与之前的可信 IP 地址相符或在可信范围之内,然后再允许其访问受限数据。例如:

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 the user's previous IP addresses, previously saved.
    return getPreviousUserIpAddresses(claims.sub);
  }).then(previousIpAddresses => {
    // Get the request IP address.
    const requestIpAddress = req.connection.remoteAddress;
    // Check if the request IP address origin is suspicious relative to previous
    // 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 location change in a
    // short period of time, then it will give stronger signals of possible abuse.
    if (!isValidIpAddress(previousIpAddresses, requestIpAddress)) {
      // Invalid IP address, take action quickly and revoke all user's refresh tokens.
      revokeUserTokens(claims.uid).then(() => {
        res.status(401).send({error: 'Unauthorized access. Please login again!'});
      }, error => {
        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!' })
      });
    }
  });
});