跳到主要内容

订阅和处理用户信息变更

概述

通过订阅用户信息变更,您可以接收有关用户及其账户的重要更新。当用户及其账户信息发生变更时,华为账号服务器会发送通知到应用服务端,应用服务端可以根据通知消息进行自身业务处理。

用户信息变更事件介绍

消息名称事件类型事件描述
tokens-revokedhttps://schemas.openid.net/secevent/oauth/event-type/tokens-revoked用户取消应用的授权
account-purgedhttps://schemas.openid.net/secevent/risc/event-type/account-purged用户注销华为账号
phone-modifiedhttps://schemas.openid.net/secevent/oauth/event-type/phone-modified用户授权手机号变更

订阅用户信息变更

订阅步骤如下:

  1. 登录华为开发者联盟,选择“管理中心 > API服务 > API库”。

  2. 选择项目,然后在API名称搜索框检索关键字“RISC”,找到RISC点击进入详情。

  3. 点击启用按钮。

  4. 点击订阅通知按钮,在弹窗中配置回调地址及订阅范围。

    回调地址:在开启订阅通知后,若华为用户信息发生变更,会发送通知消息到该地址。

    订阅范围:订阅的用户信息变更事件,详见用户信息变更事件介绍

处理通知消息

华为账号服务器向开发者应用服务端投递消息。开发者应用服务端接收到消息后需要先对消息头中的令牌进行验签,确保消息的完整有效性后解析并获取用户信息变更事件详情。具体步骤如下:

  1. 验证消息头中的令牌签名。

    您可通过任何JWT库(例如:jwt.io)对其进行解析与验证。

    无论使用哪种库,您均需完成如下操作:

    1. 调用接口(https://risc.cloud.huawei.com/v1beta/public/risc/.well-known/risc-configuration),获取发行者标识(issuer)与签名密钥证书URI(jwks\_uri)
    2. 通过依赖的JWT库,对消息头中的令牌进行解析,获取签名的KeyId。
    3. 通过签名的KeyId,从签名密钥证书URI中获取到JWT签名的公钥。
    4. 校验JWT签名中的aud与订阅用户信息变更中提供的Client ID一致。
    5. 校验JWT签名中的issuer与发行者标识(issuer)一致。

    具体验签逻辑,请参考如下示例代码:

    Maven依赖配置

    <dependencies>
    <dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.9.3</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>com.auth0</groupId>
    <artifactId>jwks-rsa</artifactId>
    <version>0.21.2</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.11.5</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.11.5</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
    <version>2.0.51</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.6</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <version>1.18.26</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    <dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.11</version> <!--此处替换为您项目需要的版本-->
    </dependency>
    </dependencies>

    Java验签代码示例

    import com.alibaba.fastjson2.JSON;
    import com.alibaba.fastjson2.JSONObject;
    import com.auth0.jwk.JwkProvider;
    import com.auth0.jwk.UrlJwkProvider;
    import com.github.benmanes.caffeine.cache.CacheLoader;
    import com.github.benmanes.caffeine.cache.Caffeine;
    import com.github.benmanes.caffeine.cache.LoadingCache;
    import io.jsonwebtoken.Claims;
    import io.jsonwebtoken.IncorrectClaimException;
    import io.jsonwebtoken.JwsHeader;
    import io.jsonwebtoken.Jwt;
    import io.jsonwebtoken.JwtParser;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.SigningKeyResolver;
    import io.jsonwebtoken.security.SignatureException;
    import lombok.Data;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.http.HttpEntity;
    import org.apache.http.HttpStatus;
    import org.apache.http.client.config.RequestConfig;
    import org.apache.http.client.methods.CloseableHttpResponse;
    import org.apache.http.client.methods.HttpGet;
    import org.apache.http.config.Registry;
    import org.apache.http.config.RegistryBuilder;
    import org.apache.http.conn.socket.ConnectionSocketFactory;
    import org.apache.http.conn.socket.PlainConnectionSocketFactory;
    import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
    import org.apache.http.impl.client.CloseableHttpClient;
    import org.apache.http.impl.client.HttpClients;
    import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
    import org.apache.http.util.EntityUtils;
    import org.checkerframework.checker.nullness.qual.NonNull;
    import org.checkerframework.checker.nullness.qual.Nullable;
    import javax.net.ssl.SSLContext;
    import javax.net.ssl.TrustManagerFactory;
    import java.io.IOException;
    import java.net.URL;
    import java.security.Key;
    import java.security.KeyManagementException;
    import java.security.KeyStore;
    import java.security.KeyStoreException;
    import java.security.NoSuchAlgorithmException;
    import java.security.PublicKey;
    import java.util.Objects;
    import java.util.concurrent.TimeUnit;

    /**
    * 订阅和处理用户信息变更
    */
    @Slf4j
    public class RiscDemo {
    /**
    * 公开配置信息地址
    */
    private static final String PUBLIC_CONFIGURATION_URL = "https://risc.cloud.huawei.com/v1beta/public/risc/.well-known/risc-configuration";

    /**
    * 公开信息缓存
    */
    private final LoadingCache<String, PublicConfiguration> publicConfigurationCache = Caffeine.newBuilder()
    .expireAfterWrite(1, TimeUnit.DAYS)
    .build(key -> {
    HttpGet request = new HttpGet(PUBLIC_CONFIGURATION_URL);
    try (CloseableHttpResponse response = getClient().execute(request)) {
    HttpEntity responseEntity = response.getEntity();
    String ret = responseEntity != null ? EntityUtils.toString(responseEntity) : null;
    EntityUtils.consume(responseEntity);
    int statusCode = response.getStatusLine().getStatusCode();
    // http状态码不是200,抛出异常
    if (statusCode != HttpStatus.SC_OK) {
    throw new IOException("call failed! http status code: " + statusCode + ", response data: " + ret);
    }
    JSONObject configJson = (JSONObject) JSON.parse(ret);
    if (configJson == null) {
    throw new IllegalArgumentException("response param error! http status code: " + statusCode + ", response data: " + ret);
    }
    String issuer = configJson.getString("issuer");
    String jwksUri = configJson.getString("jwks_uri");
    if (Objects.isNull(issuer) || Objects.isNull(jwksUri)) {
    throw new IllegalArgumentException("response param error! http status code: " + statusCode + ", response data: " + ret);
    }
    PublicConfiguration publicConfiguration = new PublicConfiguration();
    publicConfiguration.setIssuer(issuer);
    publicConfiguration.setJwksUri(jwksUri);
    return publicConfiguration;
    }
    });

    /**
    * 公钥信息缓存
    */
    private final LoadingCache<String, PublicKey> publicKeyCache = Caffeine.newBuilder()
    .expireAfterWrite(1, TimeUnit.DAYS)
    .build(new CacheLoader<String, PublicKey>() {
    @Override
    public @Nullable PublicKey load(@NonNull String key) throws Exception {
    PublicConfiguration publicConfiguration = getPublicConfiguration();
    JwkProvider huaweiCerts = new UrlJwkProvider(new URL(publicConfiguration.getJwksUri()), null, null);
    return huaweiCerts.get(key).getPublicKey();
    }
    });

    /**
    * 调试方法入口
    * @param args main方法入参
    */
    public static void main(String[] args) {
    // 消息请求头中Authorization: Bearer <token>中的<token>
    String token = "<token>";
    // Client ID
    String clientId = "<Client ID>";
    Jwt<?, ?> jwt = new RiscDemo().validateSecurityEventToken(token, clientId);
    if (Objects.isNull(jwt)) {
    // 验签失败
    log.error("verify sign failed");
    } else {
    // 验签成功
    log.info("verify sign success");
    }
    }

    /**
    * 对Authorization头域中的token进行验签
    *
    * @param token 消息请求头中Authorization: Bearer <token>中的<token>
    * @param clientId clientId
    * @return 返回为null,则表示验签失败,否则表示验证成功
    */
    public Jwt<?, ?> validateSecurityEventToken(String token, String clientId) {
    try {
    // 公开配置信息中的issuer值
    String issuer = getPublicConfiguration().getIssuer();
    SigningKeyResolver signingKeyResolver = new SigningKeyResolver() {
    private PublicKey getPublicKey(JwsHeader<?> jwsHeader) {
    try {
    return publicKeyCache.get(jwsHeader.getKeyId());
    } catch (Exception e) {
    throw new RuntimeException(e);
    }
    }
    @Override
    public Key resolveSigningKey(JwsHeader jwsHeader, Claims claims) {
    return getPublicKey(jwsHeader);
    }
    @Override
    public Key resolveSigningKey(JwsHeader jwsHeader, String s) {
    return getPublicKey(jwsHeader);
    }
    };
    // 验证并解析消息内容
    JwtParser parser = Jwts.parserBuilder()
    .requireIssuer(issuer)
    .requireAudience(clientId)
    .setAllowedClockSkewSeconds(60)
    .setSigningKeyResolver(signingKeyResolver)
    .build();
    return parser.parse(token);
    } catch (IncorrectClaimException e) {
    // 消息的claim无效,针对异常进行处理(如:日志记录)
    log.error("claim invalid", e);
    } catch (SignatureException e) {
    // 验签失败,针对异常进行处理(如:日志记录)
    log.error("verify signature failed", e);
    } catch (Exception e) {
    // 其他异常,业务自行处理
    log.error("valid event token failed", e);
    }
    return null;
    }

    private PublicConfiguration getPublicConfiguration() {
    PublicConfiguration publicConfiguration = this.publicConfigurationCache.get("DEFAULT");
    if (publicConfiguration == null) {
    throw new IllegalArgumentException("public configuration get failed!");
    }
    return publicConfiguration;
    }

    private static CloseableHttpClient getClient() {
    PoolingHttpClientConnectionManager connectionManager = buildConnectionManager(
    new String[] {"TLSv1.2"}, new String[] {
    "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
    "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
    });
    connectionManager.setMaxTotal(400);
    connectionManager.setDefaultMaxPerRoute(400);
    RequestConfig config = RequestConfig.custom()
    .setConnectionRequestTimeout(100)
    .setRedirectsEnabled(false)
    .build();
    return HttpClients.custom()
    .useSystemProperties()
    .setConnectionManager(connectionManager)
    .setDefaultRequestConfig(config)
    .build();
    }

    private static PoolingHttpClientConnectionManager buildConnectionManager(String[] supportedProtocols,
    String[] supportedCipherSuites) {
    PoolingHttpClientConnectionManager connectionManager = null;
    try {
    SSLContext sc = SSLContext.getInstance("TLSv1.2");
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmf.init((KeyStore) null);
    sc.init(null, tmf.getTrustManagers(), null);
    SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sc, supportedProtocols,
    supportedCipherSuites, SSLConnectionSocketFactory.getDefaultHostnameVerifier());
    Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
    .register("http", new PlainConnectionSocketFactory())
    .register("https", sslsf)
    .build();
    connectionManager = new PoolingHttpClientConnectionManager(registry);
    } catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
    log.error("build connect manager failed", e);
    }
    return connectionManager;
    }

    @Data
    static class PublicConfiguration {
    private String issuer;
    private String jwksUri;
    }
    }
  2. 处理消息体。

    • JSON对象格式消息体

      消息示例:用户注销华为账号

      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1727619834,
      "jti": "6672ed7d5c5e4c3c92f343ecac40f326",
      "events": {
      "https://schemas.openid.net/secevent/risc/event-type/account-purged": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      }
      }
      }
      }

      消息示例:用户取消应用的授权

      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750403661,
      "jti": "97af1abdbbcd4f00a6d8b74c9b1bbb56",
      "events": {
      "https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      },
      "scopes": [
      "phone",
      "userConsent",
      "openid",
      "email"
      ]
      }
      }
      }

      消息示例:用户授权手机号变更

      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750385669,
      "jti": "c27c197ba5c94081aa32b8dbc52389f3",
      "events": {
      "https://schemas.openid.net/secevent/oauth/event-type/phone-modified": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      }
      }
      }
      }
    • JSON数组格式消息体

      消息示例:用户注销华为账号

      [
      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750385669,
      "jti": "6672ed7d5c5e4c3c92f343ecac40f326",
      "events": {
      "https://schemas.openid.net/secevent/risc/event-type/account-purged": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      }
      }
      }
      },
      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750385669,
      "jti": "6672ed7d5c5e4c3c92f343ecac40f325",
      "events": {
      "https://schemas.openid.net/secevent/risc/event-type/account-purged": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      }
      }
      }
      }
      ]

      消息示例:用户取消应用的授权

      [
      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750403661,
      "jti": "97af1abdbbcd4f00a6d8b74c9b1bbb56",
      "events": {
      "https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      },
      "scopes": [
      "phone",
      "userConsent",
      "openid",
      "email"
      ]
      }
      }
      },
      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750403661,
      "jti": "97af1abdbbcd4f00a6d8b74c9b1bbb57",
      "events": {
      "https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      },
      "scopes": [
      "phone",
      "userConsent",
      "openid",
      "email"
      ]
      }
      }
      }
      ]

      消息示例:用户授权手机号变更

      [
      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750385669,
      "jti": "c27c197ba5c94081aa32b8dbc52389f3",
      "events": {
      "https://schemas.openid.net/secevent/oauth/event-type/phone-modified": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      }
      }
      }
      },
      {
      "iss": "id.cloud.huawei.com",
      "aud": "<Client ID>",
      "iat": 1750385669,
      "jti": "c27c197ba5c94081aa32b8dbc52389f4",
      "events": {
      "https://schemas.openid.net/secevent/oauth/event-type/phone-modified": {
      "subject": {
      "extra": "<触发事件用户的OpenID>",
      "iss": "id.cloud.huawei.com",
      "sub": "<触发事件用户的UnionID>",
      "subject_type": "iss_sub"
      }
      }
      }
      }
      ]

    其中,各字段含义如下:

    参数描述
    audClient ID(与订阅用户信息变更中提供的Client ID一致)。
    iss消息投递者标识,固定值“id.cloud.huawei.com”。
    iat生成该事件的UTC时间戳(秒级)。
    jti唯一随机字符串,用于标识此消息体,开发者可根据此字段来识别重投消息体。
    events用户信息变更事件与事件消息体,格式为json。key是用户信息变更事件类型,value为其对应事件消息信息。
    subject用户信息变更事件对应的消息体,格式为json,包含字段说明如下: - sub:触发事件用户的UnionID(用户在同一个开发者下的所有应用中,此值唯一)。具体格式要求请参考OpenID和UnionID的格式说明。 - subject_type:固定为“iss_sub”。 - extra:触发事件用户的OpenID(用户在同一个应用中,此值唯一)。具体格式要求请参考OpenID和UnionID的格式说明。 - iss:标识消息投递者,固定为“id.cloud.huawei.com”。
    scopes取消授权的scope列表,格式为json数组。在事件类型为【https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked】时才存在此字段