如果您的应用允许用户使用 Google 账号登录,您可以监听并响应跨账号保护服务提供的安全事件通知,从而提高这些共享用户账号的安全性。
这些通知会提醒您用户 Google 账号的重大更改,这些更改通常也会对用户在您的应用中的账号产生安全影响。例如,如果用户的 Google 账号被盗,可能会导致用户在您的应用中的账号通过电子邮件账号恢复或使用单点登录而被盗用。
为了帮助您降低此类事件的潜在风险,Google 会向您的服务发送名为安全事件令牌的服务对象。这些令牌只会公开极少量的信息,即安全事件的类型、发生时间以及受影响用户的标识符,但您可以使用这些信息采取适当的应对措施。例如,如果用户的 Google 账号被盗,您可以暂时为该用户停用“使用 Google 账号登录”功能,并阻止向用户的 Gmail 邮箱发送账号恢复电子邮件。
跨账号保护基于 RISC 标准,由 OpenID 基金会开发。
概览
如需将跨账号保护与您的应用或服务搭配使用,您必须完成以下任务:
在 API 控制台中设置项目。
创建事件接收器端点,Google 会向该端点发送安全事件令牌。此端点负责验证收到的令牌,然后以您选择的任何方式响应安全事件。
向 Google 注册您的端点,开始接收安全事件令牌。
前提条件
您只会收到已向您的服务授予访问其个人资料信息或电子邮件地址权限的 Google 用户的安全事件令牌。您可以通过请求 profile 或 email 范围来获取此权限。较新的
“使用 Google 账号登录”或旧版
Google 登录 SDK 默认会请求这些范围,但
如果您不使用默认设置,或者直接访问 Google 的OpenID
Connect 端点,请确保
您至少请求了其中一个范围。
在 API 控制台中设置项目
在开始接收安全事件令牌之前,您必须创建服务账号,并在 API 控制台项目中启用 RISC API。您必须使用在应用中访问 Google 服务(例如 Google 登录)时使用的同一 API 控制台项目。
如需创建服务账号,请执行以下操作:
打开 API 控制台 “凭据”页面。系统提示时,选择在应用中访问 Google 服务时使用的 API 控制台项目。
点击创建凭据 > 服务账号 。
为新创建的服务账号创建密钥。选择 JSON 密钥类型,然后点击创建 。创建密钥后,您将下载一个包含服务账号凭据的 JSON 文件。请将此文件保存在安全的位置,但也要确保您的事件接收器端点可以访问该文件。
在项目的“凭据”页面上,还要记下您用于“使用 Google 账号登录”或 Google 登录(旧版)的客户端 ID。通常,您为支持的每个平台都有一个客户端 ID。您将需要这些客户端 ID 来验证安全事件令牌,如下一部分所述。
如需启用 RISC API,请执行以下操作:
在 API 控制台中打开RISC API 页面。确保您用于访问 Google 服务的项目仍处于选中状态。
阅读 RISC 条款,确保您了解相关要求。
如果您要为组织拥有的项目启用 API,请确保您已获得将组织绑定到 RISC 条款的授权。
只有在您同意 RISC 条款的情况下,才点击启用 。
创建事件接收器端点
如需接收来自 Google 的安全事件通知,请创建一个处理 HTTPS POST 请求的 HTTPS 端点。注册此端点后(见下文),Google 将开始向该端点发布加密签名的字符串,称为安全事件令牌。安全事件令牌是已签名的 JWT,其中包含有关单个安全相关事件的信息。
对于您在端点收到的每个安全事件令牌,请先验证并解码该令牌,然后根据您的服务处理相应的安全事件。在解码之前验证事件令牌至关重要 ,以防止恶意攻击。以下部分介绍了这些任务:
1. 解码并验证安全事件令牌
由于安全性事件令牌是一种特定的 JWT,因此您可以使用任何 JWT 库(例如 jwt.io 上列出的库)来解码和验证它们。无论您使用哪个库,您的令牌验证代码都必须执行以下操作:
- 从 Google 的 RISC 配置文档中获取跨账号保护发布者标识符 (
issuer) 和签名密钥证书 URI (jwks_uri),您可以在https://accounts.google.com/.well-known/risc-configuration中找到该文档。 - 使用您选择的 JWT 库,从安全事件令牌的标头中获取签名密钥 ID。
- 从 Google 的签名密钥证书文档中,获取在上一步中获取的密钥 ID 的公钥。如果该文档不包含具有您要查找的 ID 的密钥,则安全事件令牌可能无效,您的端点应返回 HTTP 错误 400。
- 使用您选择的 JWT 库,验证以下内容:
- 安全事件令牌是使用在上一步中获取的公钥签名的。
- 令牌的
aud声明是您的某个应用的客户端 ID。 - 令牌的
iss声明与您从 RISC 发现文档中获取的发布者标识符一致。请注意,您无需验证令牌的到期时间 (exp),因为安全事件令牌代表历史事件,因此不会过期。
例如:
Java
使用 java-jwt 和 jwks-rsa-java:
public DecodedJWT validateSecurityEventToken(String token) {
DecodedJWT jwt = null;
try {
// In a real implementation, get these values from
// https://accounts.google.com/.well-known/risc-configuration
String issuer = "accounts.google.com";
String jwksUri = "https://www.googleapis.com/oauth2/v3/certs";
// Get the ID of the key used to sign the token.
DecodedJWT unverifiedJwt = JWT.decode(token);
String keyId = unverifiedJwt.getKeyId();
// Get the public key from Google.
JwkProvider googleCerts = new UrlJwkProvider(new URL(jwksUri), null, null);
PublicKey publicKey = googleCerts.get(keyId).getPublicKey();
// Verify and decode the token.
Algorithm rsa = Algorithm.RSA256((RSAPublicKey) publicKey, null);
JWTVerifier verifier = JWT.require(rsa)
.withIssuer(issuer)
// Get your apps' client IDs from the API console:
// https://console.developers.google.com/apis/credentials?project=_
.withAudience("123456789-abcedfgh.apps.googleusercontent.com",
"123456789-ijklmnop.apps.googleusercontent.com",
"123456789-qrstuvwx.apps.googleusercontent.com")
.acceptLeeway(Long.MAX_VALUE) // Don't check for expiration.
.build();
jwt = verifier.verify(token);
} catch (JwkException e) {
// Key not found. Return HTTP 400.
} catch (InvalidClaimException e) {
} catch (JWTDecodeException exception) {
// Malformed token. Return HTTP 400.
} catch (MalformedURLException e) {
// Invalid JWKS URI.
}
return jwt;
}
Python
import json
import jwt # pip install pyjwt
import requests # pip install requests
def validate_security_token(token, client_ids):
# Get Google's RISC configuration.
risc_config_uri = 'https://accounts.google.com/.well-known/risc-configuration'
risc_config = requests.get(risc_config_uri).json()
# Get the public key used to sign the token.
google_certs = requests.get(risc_config['jwks_uri']).json()
jwt_header = jwt.get_unverified_header(token)
key_id = jwt_header['kid']
public_key = None
for key in google_certs['keys']:
if key['kid'] == key_id:
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(key))
if not public_key:
raise Exception('Public key certificate not found.')
# In this situation, return HTTP 400
# Decode the token, validating its signature, audience, and issuer.
try:
token_data = jwt.decode(token, public_key, algorithms='RS256',
options={'verify_exp': False},
audience=client_ids, issuer=risc_config['issuer'])
except:
raise
# Validation failed. Return HTTP 400.
return token_data
# Get your apps' client IDs from the API console:
# https://console.developers.google.com/apis/credentials?project=_
client_ids = ['123456789-abcedfgh.apps.googleusercontent.com',
'123456789-ijklmnop.apps.googleusercontent.com',
'123456789-qrstuvwx.apps.googleusercontent.com']
token_data = validate_security_token(token, client_ids)
如果令牌有效且已成功解码,则返回 HTTP 状态 202。 然后,处理令牌指示的安全事件。
2. 处理安全事件
解码后,安全事件令牌类似于以下示例:
{
"iss": "https://accounts.google.com/",
"aud": "123456789-abcedfgh.apps.googleusercontent.com",
"iat": 1508184845,
"jti": "756E69717565206964656E746966696572",
"events": {
"https://schemas.openid.net/secevent/risc/event-type/account-disabled": {
"subject": {
"subject_type": "iss-sub",
"iss": "https://accounts.google.com/",
"sub": "7375626A656374"
},
"reason": "hijacking"
}
}
}
iss 和 aud 声明指示令牌的发布者 (Google) 和令牌的预期接收者(您的服务)。您在上一步中验证了这些声明。
jti 声明是一个字符串,用于标识单个安全事件,并且对于流是唯一的。您可以使用此标识符来跟踪您收到的安全事件。
events 声明包含有关令牌所代表的安全事件的信息。此声明是从事件类型标识符到 subject 声明的映射,该声明指定了此事件涉及的用户,以及可能提供的有关该事件的任何其他详细信息。
subject 声明使用用户的唯一 Google
账号 ID (sub) 标识特定用户。此 Google 账号 ID 与较新的“使用 Google 账号登录”(JavaScript
、HTML)、旧版 Google 登录库或
OpenID Connect 发布的 JWT ID 令牌中包含的标识符 (sub) 相同。当声明的 subject_type 为 id_token_claims 时,它可能还包含一个带有用户电子邮件地址的 email 字段。
使用 events 声明中的信息,针对指定用户账号中的事件类型采取适当的操作。
OAuth 令牌标识符
对于有关各个令牌的 OAuth 事件,令牌正文标识符类型包含以下字段:
token_type:仅支持refresh_token。token_identifier_alg:如需了解可能的值,请参阅下表。token:请参阅下表。
| token_identifier_alg | token |
|---|---|
prefix |
令牌的前 16 个字符。 |
hash_base64_sha512_sha512 |
使用 SHA-512 的令牌的双重哈希。 |
如果您与这些事件集成,建议您根据这些可能的值为令牌编制索引,以确保在收到事件时快速匹配。
支持的事件类型
跨账号保护支持以下类型的安全事件:
| 事件类型 | 属性 | 如何响应 |
|---|---|---|
https://schemas.openid.net/secevent/risc/event-type/sessions-revoked |
必需:结束用户当前打开的会话,重新保护用户的账号。 | |
https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked |
必需:如果令牌用于 Google 登录,请结束用户当前打开的会话。此外,您可能需要建议用户设置备用登录方法。 建议:如果令牌用于访问其他 Google API,请删除 您存储的任何用户 OAuth 令牌。 |
|
https://schemas.openid.net/secevent/oauth/event-type/token-revoked |
如需了解令牌标识符,请参阅 OAuth 令牌标识符 部分 |
必需:如果您存储了相应的刷新令牌,请将其删除 并在下次需要访问令牌时请求用户重新同意。 |
https://schemas.openid.net/secevent/risc/event-type/account-disabled |
reason=hijacking,reason=bulk-account |
必需:如果账号被停用的原因是
建议:如果账号被停用的原因是
建议:如果未提供任何原因,请为用户停用 Google 登录,并停用使用与用户 Google 账号关联的电子邮件地址(通常但不一定是 Gmail 账号)进行账号恢复的功能。为用户提供备用登录方法。 |
https://schemas.openid.net/secevent/risc/event-type/account-enabled |
建议:为用户重新启用 Google 登录,并使用用户的 Google 账号电子邮件地址重新启用 账号恢复功能。 | |
https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required |
建议:留意您的服务中的可疑活动,并采取 适当的操作。 | |
https://schemas.openid.net/secevent/risc/event-type/verification |
state=state | 建议:记录收到测试令牌。 |
重复和遗漏的事件
跨账号保护会尝试重新传送它认为尚未传送的事件。因此,您有时可能会多次收到同一事件。如果这可能会导致重复操作,给用户带来不便,请考虑使用 jti 声明(事件的唯一标识符)来对事件进行去重。有一些外部工具(例如 Google Cloud
Dataflow)可以帮助您执行
去重数据流。
请注意,事件的传送重试次数有限,因此如果您的接收器长时间处于关闭状态,您可能会永久遗漏一些事件。
注册接收器
如需开始接收安全事件,请使用 RISC API 注册接收器端点。对 RISC API 的调用必须附带授权令牌。
您只会收到应用用户的安全事件,因此您需要在 GCP 项目中配置 OAuth 权限请求页面,作为执行以下步骤的前提条件。
1. 生成授权令牌
如需为 RISC API 生成授权令牌,请使用以下声明创建 JWT:
{
"iss": SERVICE_ACCOUNT_EMAIL,
"sub": SERVICE_ACCOUNT_EMAIL,
"aud": "https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService",
"iat": CURRENT_TIME,
"exp": CURRENT_TIME + 3600
}使用服务账号的私钥对 JWT 进行签名,您可以在创建服务账号密钥时下载的 JSON 文件中找到该私钥。
例如:
Java
使用 java-jwt 和 Google 的身份验证库:
public static String makeBearerToken() {
String token = null;
try {
// Get signing key and client email address.
FileInputStream is = new FileInputStream("your-service-account-credentials.json");
ServiceAccountCredentials credentials =
(ServiceAccountCredentials) GoogleCredentials.fromStream(is);
PrivateKey privateKey = credentials.getPrivateKey();
String keyId = credentials.getPrivateKeyId();
String clientEmail = credentials.getClientEmail();
// Token must expire in exactly one hour.
Date issuedAt = new Date();
Date expiresAt = new Date(issuedAt.getTime() + 3600000);
// Create signed token.
Algorithm rsaKey = Algorithm.RSA256(null, (RSAPrivateKey) privateKey);
token = JWT.create()
.withIssuer(clientEmail)
.withSubject(clientEmail)
.withAudience("https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService")
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.withKeyId(keyId)
.sign(rsaKey);
} catch (ClassCastException e) {
// Credentials file doesn't contain a service account key.
} catch (IOException e) {
// Credentials file couldn't be loaded.
}
return token;
}
Python
import json
import time
import jwt # pip install pyjwt
def make_bearer_token(credentials_file):
with open(credentials_file) as service_json:
service_account = json.load(service_json)
issuer = service_account['client_email']
subject = service_account['client_email']
private_key_id = service_account['private_key_id']
private_key = service_account['private_key']
issued_at = int(time.time())
expires_at = issued_at + 3600
payload = {'iss': issuer,
'sub': subject,
'aud': 'https://risc.googleapis.com/google.identity.risc.v1beta.RiscManagementService',
'iat': issued_at,
'exp': expires_at}
encoded = jwt.encode(payload, private_key, algorithm='RS256',
headers={'kid': private_key_id})
return encoded
auth_token = make_bearer_token('your-service-account-credentials.json')
此授权令牌可用于进行 RISC API 调用一小时。令牌过期后,请生成新令牌以继续进行 RISC API 调用。
2. 调用 RISC 流配置 API
现在您有了授权令牌,可以使用 RISC API 配置项目的安全事件流,包括注册接收器端点。
为此,请向 https://risc.googleapis.com/v1beta/stream:update 发出 HTTPS POST 请求,
指定您的接收器端点以及您感兴趣的安全
事件类型:
POST /v1beta/stream:update HTTP/1.1
Host: risc.googleapis.com
Authorization: Bearer AUTH_TOKEN
{
"delivery": {
"delivery_method":
"https://schemas.openid.net/secevent/risc/delivery-method/push",
"url": RECEIVER_ENDPOINT
},
"events_requested": [
SECURITY_EVENT_TYPES
]
}
例如:
Java
public static void configureEventStream(final String receiverEndpoint,
final List<String> eventsRequested,
String authToken) throws IOException {
ObjectMapper jsonMapper = new ObjectMapper();
String streamConfig = jsonMapper.writeValueAsString(new Object() {
public Object delivery = new Object() {
public String delivery_method =
"https://schemas.openid.net/secevent/risc/delivery-method/push";
public String url = receiverEndpoint;
};
public List<String> events_requested = eventsRequested;
});
HttpPost updateRequest = new HttpPost("https://risc.googleapis.com/v1beta/stream:update");
updateRequest.addHeader("Content-Type", "application/json");
updateRequest.addHeader("Authorization", "Bearer " + authToken);
updateRequest.setEntity(new StringEntity(streamConfig));
HttpResponse updateResponse = new DefaultHttpClient().execute(updateRequest);
Header[] responseContentTypeHeaders = updateResponse.getHeaders("Content-Type");
StatusLine responseStatus = updateResponse.getStatusLine();
int statusCode = responseStatus.getStatusCode();
HttpEntity entity = updateResponse.getEntity();
// Now handle response
}
// ...
configureEventStream(
"https://your-service.example.com/security-event-receiver",
Arrays.asList(
"https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required",
"https://schemas.openid.net/secevent/risc/event-type/account-disabled"),
authToken);
Python
import requests
def configure_event_stream(auth_token, receiver_endpoint, events_requested):
stream_update_endpoint = 'https://risc.googleapis.com/v1beta/stream:update'
headers = {'Authorization': 'Bearer {}'.format(auth_token)}
stream_cfg = {'delivery': {'delivery_method': 'https://schemas.openid.net/secevent/risc/delivery-method/push',
'url': receiver_endpoint},
'events_requested': events_requested}
response = requests.post(stream_update_endpoint, json=stream_cfg, headers=headers)
response.raise_for_status() # Raise exception for unsuccessful requests
configure_event_stream(auth_token, 'https://your-service.example.com/security-event-receiver',
['https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required',
'https://schemas.openid.net/secevent/risc/event-type/account-disabled'])
如果请求返回 HTTP 200,则表示事件流已成功配置,您的接收器端点应开始接收安全事件令牌。下一部分介绍了如何测试流配置和端点,以验证一切是否正常运行。
获取和更新当前流配置
如果您将来想要修改流配置,可以向 https://risc.googleapis.com/v1beta/stream 发出授权的 GET 请求来获取当前流配置,修改响应正文,然后按照上述说明将修改后的配置 POST 回 https://risc.googleapis.com/v1beta/stream:update。
停止和恢复事件流
如果您需要停止来自 Google 的事件流,请向 https://risc.googleapis.com/v1beta/stream/status:update 发出授权的 POST
请求,并在请求正文中添加 { "status": "disabled" }
。在流停用期间,Google 不会向您的端点发送事件,也不会在发生安全事件时缓冲这些事件。如需
重新启用事件流,请将 { "status": "enabled" } POST 到同一端点。
3. 可选:测试流配置
您可以通过事件流发送验证令牌,验证流配置和接收器端点是否正常协同工作。 此令牌可以包含一个唯一字符串,您可以使用该字符串验证令牌是否已在您的端点收到。如需使用此流程,请务必在注册接收器时订阅 https://schemas.openid.net/secevent/risc/event-type/verification 事件类型。
如需请求验证令牌,请向 https://risc.googleapis.com/v1beta/stream:verify 发出授权的 HTTPS POST 请求。在请求正文中,指定一些标识字符串:
{
"state": "ANYTHING"
}
例如:
Java
public static void testEventStream(final String stateString,
String authToken) throws IOException {
ObjectMapper jsonMapper = new ObjectMapper();
String json = jsonMapper.writeValueAsString(new Object() {
public String state = stateString;
});
HttpPost updateRequest = new HttpPost("https://risc.googleapis.com/v1beta/stream:verify");
updateRequest.addHeader("Content-Type", "application/json");
updateRequest.addHeader("Authorization", "Bearer " + authToken);
updateRequest.setEntity(new StringEntity(json));
HttpResponse updateResponse = new DefaultHttpClient().execute(updateRequest);
Header[] responseContentTypeHeaders = updateResponse.getHeaders("Content-Type");
StatusLine responseStatus = updateResponse.getStatusLine();
int statusCode = responseStatus.getStatusCode();
HttpEntity entity = updateResponse.getEntity();
// Now handle response
}
// ...
testEventStream("Test token requested at " + new Date().toString(), authToken);
Python
import requests
import time
def test_event_stream(auth_token, nonce):
stream_verify_endpoint = 'https://risc.googleapis.com/v1beta/stream:verify'
headers = {'Authorization': 'Bearer {}'.format(auth_token)}
state = {'state': nonce}
response = requests.post(stream_verify_endpoint, json=state, headers=headers)
response.raise_for_status() # Raise exception for unsuccessful requests
test_event_stream(auth_token, 'Test token requested at {}'.format(time.ctime()))
如果请求成功,验证令牌将发送到您注册的端点。然后,例如,如果您的端点通过简单地记录验证令牌来处理这些令牌,您可以检查日志以确认令牌已收到。
错误代码参考
RISC API 可以返回以下错误:
| 错误代码 | 错误消息 | 建议采取的措施 |
|---|---|---|
| 400 | 流配置必须包含 $fieldname 字段。 | 您向 https://risc.googleapis.com/v1beta/stream:update 端点发出的请求无效或无法 解析。请在请求中添加 $fieldname。 |
| 401 | 未授权。 | 授权失败。请确保您在请求中附上了 授权令牌,并且该令牌有效 且未过期。 |
| 403 | 传送端点必须是 HTTPS 网址。 | 您的传送端点(即您希望 RISC 事件传送到的 端点)必须是 HTTPS。我们不会将 RISC 事件发送到 HTTP 网址。 |
| 403 | 现有流配置没有符合规范的 RISC 传送方法。 | 您的 Google Cloud 云项目必须已具有 RISC 配置。如果您使用的是 Firebase 并且启用了 Google 登录,则 Firebase 将为您的项目管理 RISC;您将无法创建自定义配置。如果您没有为 Firebase 项目使用 Google 登录, 请将其停用,然后在 1 小时后再次尝试更新。 |
| 403 | 找不到项目。 | 确保您为正确的 项目使用了正确的服务账号。您可能使用的是与已删除 项目关联的服务账号。了解如何查看与项目关联的所有服务账号 。 |
| 403 | 服务账号需要获得访问您的 RISC 配置的权限 | 前往项目的 API 控制台,然后按照以下说明,向调用项目的服务账号分配“RISC 配置管理员”角色 (roles/riscconfigs.admin)。 |
| 403 | 流管理 API 只能由服务账号调用。 | 如需详细了解如何使用服务账号调用 Google API,请点击此处。 |
| 403 | 传送端点不属于您项目的任何网域。 | 每个项目都有一组 授权网域。 如果您的传送端点(即您希望 RISC 事件 传送到的端点)未托管在其中一个网域上,我们需要您将该端点的网域添加到该组中。 |
| 403 | 如需使用此 API,您的项目必须至少配置一个 OAuth 客户端。 | 只有在您构建支持 Google 登录的应用时,RISC 才能正常运行。 此连接需要 OAuth 客户端。如果您的项目没有 OAuth 客户端,则 RISC 可能对您没有用。详细了解 Google 如何将 OAuth 用于我们的 API。 |
| 403 |
不支持的状态。 状态无效。 |
我们目前仅支持流状态“enabled”和
“disabled”。 |
| 404 |
项目没有 RISC 配置。 项目没有现有的 RISC 配置,无法更新状态。 |
调用 https://risc.googleapis.com/v1beta/stream:update 端点以创建新的流配置。 |
| 4XX/5XX | 无法更新状态。 | 请查看详细的错误消息以了解详情。 |
访问令牌范围
如果您决定使用访问令牌向 RISC API 进行身份验证,则您的应用必须请求以下范围:
| 端点 | 范围 |
|---|---|
https://risc.googleapis.com/v1beta/stream/status |
https://www.googleapis.com/auth/risc.status.readonly
或 https://www.googleapis.com/auth/risc.status.readwrite |
https://risc.googleapis.com/v1beta/stream/status:update |
https://www.googleapis.com/auth/risc.status.readwrite |
https://risc.googleapis.com/v1beta/stream |
https://www.googleapis.com/auth/risc.configuration.readonly
或 https://www.googleapis.com/auth/risc.configuration.readwrite
|
https://risc.googleapis.com/v1beta/stream:update |
https://www.googleapis.com/auth/risc.configuration.readwrite |
https://risc.googleapis.com/v1beta/stream:verify |
https://www.googleapis.com/auth/risc.verify |
需要帮助吗?
首先,请查看我们的错误代码参考部分。如果您仍有 疑问,请在 Stack Overflow 上使用 #SecEvents 标签提问。