אימות למינויי Push

אם מינוי דחיפה משתמש באימות, שירות Pub/Sub חותם על אסימון אינטרנט מסוג JSON‏ (JWT) ושולח את ה-JWT בכותרת ההרשאה של בקשת הדחיפה. ה-JWT כולל הצהרות וחתימה.

מנויים יכולים לאמת את ה-JWT ולוודא את הדברים הבאים:

  • הטענות מדויקות.
  • שירות Pub/Sub חתם על הטענות.

אם המנויים משתמשים בחומת אש, הם לא יכולים לקבל בקשות להצגת התראות פוש. כדי לקבל בקשות push, צריך להשבית את חומת האש ולאמת את ה-JWT. אם למנוי יש חומת אש, יכול להיות שתקבלו 403 permission deniedשגיאה.

לפני שמתחילים

פורמט JWT

ה-JWT הוא JWT של OpenIDConnect שמורכב מכותרת, מקבוצת הצהרות ומחתימה. שירות Pub/Sub מקודד את ה-JWT כמחרוזת base64 עם נקודות כמפרידים.

לדוגמה, כותרת ההרשאה הבאה כוללת JWT מקודד:

"Authorization" : "Bearer
eyJhbGciOiJSUzI1NiIsImtpZCI6IjdkNjgwZDhjNzBkNDRlOTQ3MTMzY2JkNDk5ZWJjMWE2MWMzZDVh
YmMiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tIiwiYXpwIjoiMTEzNzc0M
jY0NDYzMDM4MzIxOTY0IiwiZW1haWwiOiJnYWUtZ2NwQGFwcHNwb3QuZ3NlcnZpY2VhY2NvdW50LmNvb
SIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJleHAiOjE1NTAxODU5MzUsImlhdCI6MTU1MDE4MjMzNSwia
XNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTEzNzc0MjY0NDYzMDM4MzIxO
TY0In0.QVjyqpmadTyDZmlX2u3jWd1kJ68YkdwsRZDo-QxSPbxjug4ucLBwAs2QePrcgZ6hhkvdc4UHY
4YF3fz9g7XHULNVIzX5xh02qXEH8dK6PgGndIWcZQzjSYfgO-q-R2oo2hNM5HBBsQN4ARtGK_acG-NGG
WM3CQfahbEjZPAJe_B8M7HfIu_G5jOLZCw2EUcGo8BvEwGcLWB2WqEgRM0-xt5-UPzoa3-FpSPG7DHk7
z9zRUeq6eB__ldb-2o4RciJmjVwHgnYqn3VvlX9oVKEgXpNFhKuYA-mWh5o7BCwhujSMmFoBOh6mbIXF
cyf5UiVqKjpqEbqPGo_AvKvIQ9VTQ" 

הכותרת וקבוצת הטענות הן מחרוזות JSON. אחרי הפענוח, הם ייראו כך:

{"alg":"RS256","kid":"7d680d8c70d44e947133cbd499ebc1a61c3d5abc","typ":"JWT"}

{
   "aud":"https://example.com",
   "azp":"113774264463038321964",
   "email":"gae-gcp@appspot.s3ns-system.iam.gserviceaccount.com",
   "sub":"113774264463038321964",
   "email_verified":true,
   "exp":1550185935,
   "iat":1550182335,
   "iss":"https://accounts.google.com"
  }

יכול להיות שהטוקנים שמצורפים לבקשות שנשלחות לנקודות קצה של הודעות פוש יהיו בני עד שעה.

הגדרת Pub/Sub לאימות Push

בדוגמה הבאה אפשר לראות איך מגדירים את חשבון השירות של שירות האימות בדחיפה לחשבון שירות לפי בחירה, ואיך מעניקים את התפקיד iam.serviceAccountTokenCreator לסוכן השירות service-{PROJECT_NUMBER}@gcp-sa-pubsub.s3ns-system.iam.gserviceaccount.com.

המסוף

  1. עוברים לדף Pub/Sub Subscriptions (מינויים ל-Pub/Sub).

    כניסה לדף 'מינויים'

  2. לוחצים על יצירת מינוי.

  3. כותבים שם בשדה Subscription ID.

  4. בוחרים נושא.

  5. בוחרים באפשרות Push בתור סוג השליחה.

  6. מזינים כתובת URL של נקודת קצה.

  7. מסמנים את התיבה הפעלת אימות.

  8. בוחרים חשבון שירות.

  9. מוודאים שלסוכן השירות service-{PROJECT_NUMBER}@gcp-sa-pubsub.s3ns-system.iam.gserviceaccount.com מוקצה התפקיד iam.serviceAccountTokenCreator בלוח הבקרה של IAM בפרויקט. אם התפקיד לא הוקצה לחשבון השירות, לוחצים על Grant (הענקת הרשאה) בלוח הבקרה של IAM כדי להקצות אותו.

  10. אופציונלי: מזינים קהל.

  11. לוחצים על יצירה.

gcloud

# Configure the push subscription
gcloud pubsub subscriptions (create|update|modify-push-config) ${SUBSCRIPTION} \
 --topic=${TOPIC} \
 --push-endpoint=${PUSH_ENDPOINT_URI} \
 --push-auth-service-account=${SERVICE_ACCOUNT_EMAIL} \
 --push-auth-token-audience=${OPTIONAL_AUDIENCE_OVERRIDE}

# Your service agent
# `service-{PROJECT_NUMBER}@gcp-sa-pubsub.s3ns-system.iam.gserviceaccount.com` needs to have the
# `iam.serviceAccountTokenCreator` role.
PUBSUB_SERVICE_ACCOUNT="service-${PROJECT_NUMBER}@gcp-sa-pubsub.s3ns-system.iam.gserviceaccount.com"
gcloud projects add-iam-policy-binding ${PROJECT_ID} \
 --member="serviceAccount:${PUBSUB_SERVICE_ACCOUNT}"\
 --role='roles/iam.serviceAccountTokenCreator'

כשמפעילים אימות למינוי לקבלת עדכונים, יכול להיות שתיתקלו בשגיאה permission denied או not authorized. כדי לפתור את הבעיה, צריך לתת לחשבון המשתמש שיזם את היצירה או העדכון של המינוי את ההרשאה iam.serviceAccounts.actAs בחשבון השירות. מידע נוסף זמין במאמר בנושא אימות בקטע 'יצירת מינויים ל-Push'.

אם אתם משתמשים במינוי דחיפה מאומת עם אפליקציית App Engine שמאובטחת באמצעות שרת proxy לאימות זהויות (IAP), אתם צריכים לספק את מזהה הלקוח של IAP כקהל של אסימון האימות של הודעות ה-Push. כדי להפעיל את IAP באפליקציית App Engine, אפשר לעיין במאמר בנושא הפעלת IAP. כדי למצוא את מזהה הלקוח של הרכישות מתוך האפליקציה, מחפשים את IAP-App-Engine-app Client ID בדף Credentials.

תלונות

אפשר להשתמש ב-JWT כדי לאמת שההצהרות – כולל ההצהרות email ו-aud – חתומות על ידי Google. למידע נוסף על השימוש בממשקי Google OAuth 2.0 API לאימות ולהרשאה, תוכלו לקרוא את המאמר OpenID Connect.

יש שני מנגנונים שנותנים משמעות לטענות האלה. קודם כול, ב-Pub/Sub נדרש שלמשתמש או לחשבון השירות שמבצעים את הקריאה ל-CreateSubscription,‏ UpdateSubscription או ModifyPushConfig יהיה תפקיד עם ההרשאה iam.serviceAccounts.actAs בחשבון השירות של שירות האימות של הודעות Push. דוגמה לתפקיד כזה היא התפקיד roles/iam.serviceAccountUser.

שנית, הגישה לאישורים שמשמשים לחתימה על האסימונים מבוקרת בקפידה. כדי ליצור את האסימון, Pub/Sub צריכה לקרוא לשירות פנימי של Google באמצעות זהות נפרדת של חשבון שירות לחתימה, שהוא סוכן השירות service-${PROJECT_NUMBER}@gcp-sa-pubsub.s3ns-system.iam.gserviceaccount.com. לחשבון השירות שמשמש לחתימה צריכה להיות ההרשאה iam.serviceAccounts.getOpenIdToken או התפקיד Service Account Token Creator ‏ (roles/iam.serviceAccountTokenCreator) בחשבון השירות של אימות בדחיפה (או בכל משאב אב, כמו הפרויקט, של חשבון השירות של אימות בדחיפה).

אימות טוקנים

כדי לאמת טוקנים שנשלחים מ-Pub/Sub לנקודת הקצה של ה-push, צריך:

  • בדיקת התקינות של הטוקן באמצעות אימות החתימה.
  • מוודאים שהטענות email ו-audience בטוקן תואמות לערכים שהוגדרו בהגדרות של מינוי דחיפה.

בדוגמה הבאה מוסבר איך לאמת בקשת push לאפליקציית App Engine שלא מאובטחת באמצעות שרת proxy לאימות זהויות (IAP). אם אפליקציית App Engine מאובטחת באמצעות IAP, כותרת בקשת ה-HTTP שמכילה את אסימון ה-JWT של IAP היא x-goog-iap-jwt-assertion, וצריך לאמת אותה בהתאם.

פרוטוקול

בקשה:

GET https://oauth2.googleapis.com/tokeninfo?id_token={BEARER_TOKEN}

תשובה:

200 OK
{
    "alg": "RS256",
    "aud": "example.com",
    "azp": "104176025330667568672",
    "email": "{SERVICE_ACCOUNT_NAME}@{YOUR_PROJECT_NAME}.s3ns.iam.gserviceaccount.com",
    "email_verified": "true",
    "exp": "1555463097",
    "iat": "1555459497",
    "iss": "https://accounts.google.com",
    "kid": "3782d3f0bc89008d9d2c01730f765cfb19d3b70e",
    "sub": "104176025330667568672",
    "typ": "JWT"
}

C#‎

לפני שמנסים את הדוגמה הזו, צריך לפעול לפי הוראות ההגדרה של C# ‎ במאמר הפעלה מהירה: שימוש בספריות לקוח. מידע נוסף מופיע במאמרי העזרה של Pub/Sub C# API.

        /// <summary>
        /// Extended JWT payload to match the pubsub payload format.
        /// </summary>
        public class PubSubPayload : JsonWebSignature.Payload
        {
            [JsonProperty("email")]
            public string Email { get; set; }
            [JsonProperty("email_verified")]
            public string EmailVerified { get; set; }
        }
        /// <summary>
        /// Handle authenticated push request coming from pubsub.
        /// See the full sample in https://github.com/GoogleCloudPlatform/dotnet-docs-samples/blob/main/appengine/flexible/Pubsub/Pubsub.Sample/Controllers/HomeController.cs
        /// </summary>
        [HttpPost]
        [Route("/AuthPush")]
        public async Task<IActionResult> AuthPushAsync([FromBody] PushBody body, [FromQuery] string token)
        {
            // Get the Cloud Pub/Sub-generated "Authorization" header.
            string authorizaionHeader = HttpContext.Request.Headers["Authorization"];
            string verificationToken = token ?? body.message.attributes["token"];
            // JWT token comes in `Bearer <JWT>` format substring 7 specifies the position of first JWT char.
            string authToken = authorizaionHeader.StartsWith("Bearer ") ? authorizaionHeader.Substring(7) : null;
            if (verificationToken != _options.VerificationToken || authToken is null)
            {
                return new BadRequestResult();
            }
            // Verify and decode the JWT.
            // Note: For high volume push requests, it would save some network
            // overhead if you verify the tokens offline by decoding them using
            // Google's Public Cert; caching already seen tokens works best when
            // a large volume of messages have prompted a single push server to
            // handle them, in which case they would all share the same token for
            // a limited time window.
            var payload = await JsonWebSignature.VerifySignedTokenAsync<PubSubPayload>(authToken);

            // IMPORTANT: you should validate payload details not covered
            // by signature and audience verification above, including:
            //   - Ensure that `payload.Email` is equal to the expected service
            //     account set up in the push subscription settings.
            //   - Ensure that `payload.Email_verified` is set to true.

            var messageBytes = Convert.FromBase64String(body.message.data);
            string message = System.Text.Encoding.UTF8.GetString(messageBytes);
            s_authenticatedMessages.Add(message);
            return new OkResult();
        }

המשך

// receiveMessagesHandler validates authentication token and caches the Pub/Sub
// message received.
func (a *app) receiveMessagesHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
		return
	}

	// Verify that the request originates from the application.
	// a.pubsubVerificationToken = os.Getenv("PUBSUB_VERIFICATION_TOKEN")
	if token, ok := r.URL.Query()["token"]; !ok || len(token) != 1 || token[0] != a.pubsubVerificationToken {
		http.Error(w, "Bad token", http.StatusBadRequest)
		return
	}

	// Get the Cloud Pub/Sub-generated JWT in the "Authorization" header.
	authHeader := r.Header.Get("Authorization")
	if authHeader == "" || len(strings.Split(authHeader, " ")) != 2 {
		http.Error(w, "Missing Authorization header", http.StatusBadRequest)
		return
	}
	token := strings.Split(authHeader, " ")[1]
	// Verify and decode the JWT.
	// If you don't need to control the HTTP client used you can use the
	// convenience method idtoken.Validate instead of creating a Validator.
	v, err := idtoken.NewValidator(r.Context(), option.WithHTTPClient(a.defaultHTTPClient))
	if err != nil {
		http.Error(w, "Unable to create Validator", http.StatusBadRequest)
		return
	}
	// Please change http://example.com to match with the value you are
	// providing while creating the subscription.
	payload, err := v.Validate(r.Context(), token, "http://example.com")
	if err != nil {
		http.Error(w, fmt.Sprintf("Invalid Token: %v", err), http.StatusBadRequest)
		return
	}
	if payload.Issuer != "accounts.google.com" && payload.Issuer != "https://accounts.google.com" {
		http.Error(w, "Wrong Issuer", http.StatusBadRequest)
		return
	}

	// IMPORTANT: you should validate claim details not covered by signature
	// and audience verification above, including:
	//   - Ensure that `payload.Claims["email"]` is equal to the expected service
	//     account set up in the push subscription settings.
	//   - Ensure that `payload.Claims["email_verified"]` is set to true.
	if payload.Claims["email"] != "test-service-account-email@example.com" || payload.Claims["email_verified"] != true {
		http.Error(w, "Unexpected email identity", http.StatusBadRequest)
		return
	}

	var pr pushRequest
	if err := json.NewDecoder(r.Body).Decode(&pr); err != nil {
		http.Error(w, fmt.Sprintf("Could not decode body: %v", err), http.StatusBadRequest)
		return
	}

	a.messagesMu.Lock()
	defer a.messagesMu.Unlock()
	// Limit to ten.
	a.messages = append(a.messages, pr.Message.Data)
	if len(a.messages) > maxMessages {
		a.messages = a.messages[len(a.messages)-maxMessages:]
	}

	fmt.Fprint(w, "OK")
}

Java

@WebServlet(value = "/pubsub/authenticated-push")
public class PubSubAuthenticatedPush extends HttpServlet {
  private final String pubsubVerificationToken = System.getenv("PUBSUB_VERIFICATION_TOKEN");
  private final MessageRepository messageRepository;
  private final GoogleIdTokenVerifier verifier =
      new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
          /**
           * Please change example.com to match with value you are providing while creating
           * subscription as provided in @see <a
           * href="https://github.com/GoogleCloudPlatform/java-docs-samples/tree/main/appengine-java8/pubsub">README</a>.
           */
          .setAudience(Collections.singletonList("example.com"))
          .build();
  private final Gson gson = new Gson();

  @Override
  public void doPost(HttpServletRequest req, HttpServletResponse resp)
      throws IOException, ServletException {

    // Verify that the request originates from the application.
    if (req.getParameter("token").compareTo(pubsubVerificationToken) != 0) {
      resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      return;
    }
    // Get the Cloud Pub/Sub-generated JWT in the "Authorization" header.
    String authorizationHeader = req.getHeader("Authorization");
    if (authorizationHeader == null
        || authorizationHeader.isEmpty()
        || authorizationHeader.split(" ").length != 2) {
      resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      return;
    }
    String authorization = authorizationHeader.split(" ")[1];

    try {
      // Verify and decode the JWT.
      // Note: For high volume push requests, it would save some network overhead
      // if you verify the tokens offline by decoding them using Google's Public
      // Cert; caching already seen tokens works best when a large volume of
      // messsages have prompted a single push server to handle them, in which
      // case they would all share the same token for a limited time window.
      GoogleIdToken idToken = verifier.verify(authorization);

      GoogleIdToken.Payload payload = idToken.getPayload();
      // IMPORTANT: you should validate claim details not covered by signature
      // and audience verification above, including:
      //   - Ensure that `payload.getEmail()` is equal to the expected service
      //     account set up in the push subscription settings.
      //   - Ensure that `payload.getEmailVerified()` is set to true.

      messageRepository.saveToken(authorization);
      messageRepository.saveClaim(payload.toPrettyString());
      // parse message object from "message" field in the request body json
      // decode message data from base64
      Message message = getMessage(req);
      messageRepository.save(message);
      // 200, 201, 204, 102 status codes are interpreted as success by the Pub/Sub system
      resp.setStatus(102);
      super.doPost(req, resp);
    } catch (Exception e) {
      resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
    }
  }

  private Message getMessage(HttpServletRequest request) throws IOException {
    String requestBody = request.getReader().lines().collect(Collectors.joining("\n"));
    JsonElement jsonRoot = JsonParser.parseString(requestBody).getAsJsonObject();
    String messageStr = jsonRoot.getAsJsonObject().get("message").toString();
    Message message = gson.fromJson(messageStr, Message.class);
    // decode from base64
    String decoded = decode(message.getData());
    message.setData(decoded);
    return message;
  }

  private String decode(String data) {
    return new String(Base64.getDecoder().decode(data));
  }

  PubSubAuthenticatedPush(MessageRepository messageRepository) {
    this.messageRepository = messageRepository;
  }

  public PubSubAuthenticatedPush() {
    this(MessageRepositoryImpl.getInstance());
  }
}

Node.js

app.post('/pubsub/authenticated-push', jsonBodyParser, async (req, res) => {
  // Verify that the request originates from the application.
  if (req.query.token !== PUBSUB_VERIFICATION_TOKEN) {
    res.status(400).send('Invalid request');
    return;
  }

  // Verify that the push request originates from Cloud Pub/Sub.
  try {
    // Get the Cloud Pub/Sub-generated JWT in the "Authorization" header.
    const bearer = req.header('Authorization');
    const [, token] = bearer.match(/Bearer (.*)/);
    tokens.push(token);

    // Verify and decode the JWT.
    // Note: For high volume push requests, it would save some network
    // overhead if you verify the tokens offline by decoding them using
    // Google's Public Cert; caching already seen tokens works best when
    // a large volume of messages have prompted a single push server to
    // handle them, in which case they would all share the same token for
    // a limited time window.
    const ticket = await authClient.verifyIdToken({
      idToken: token,
      audience: 'example.com',
    });

    const claim = ticket.getPayload();

    // IMPORTANT: you should validate claim details not covered
    // by signature and audience verification above, including:
    //   - Ensure that `claim.email` is equal to the expected service
    //     account set up in the push subscription settings.
    //   - Ensure that `claim.email_verified` is set to true.

    claims.push(claim);
  } catch (e) {
    res.status(400).send('Invalid token');
    return;
  }

  // The message is a unicode string encoded in base64.
  const message = Buffer.from(req.body.message.data, 'base64').toString(
    'utf-8'
  );

  messages.push(message);

  res.status(200).send();
});

Python

@app.route("/push-handlers/receive_messages", methods=["POST"])
def receive_messages_handler():
    # Verify that the request originates from the application.
    if request.args.get("token", "") != current_app.config["PUBSUB_VERIFICATION_TOKEN"]:
        return "Invalid request", 400

    # Verify that the push request originates from Cloud Pub/Sub.
    try:
        # Get the Cloud Pub/Sub-generated JWT in the "Authorization" header.
        bearer_token = request.headers.get("Authorization")
        token = bearer_token.split(" ")[1]
        TOKENS.append(token)

        # Verify and decode the JWT. `verify_oauth2_token` verifies
        # the JWT signature, the `aud` claim, and the `exp` claim.
        # Note: For high volume push requests, it would save some network
        # overhead if you verify the tokens offline by downloading Google's
        # Public Cert and decode them using the `google.auth.jwt` module;
        # caching already seen tokens works best when a large volume of
        # messages have prompted a single push server to handle them, in which
        # case they would all share the same token for a limited time window.
        claim = id_token.verify_oauth2_token(
            token, requests.Request(), audience="example.com"
        )

        # IMPORTANT: you should validate claim details not covered by signature
        # and audience verification above, including:
        #   - Ensure that `claim["email"]` is equal to the expected service
        #     account set up in the push subscription settings.
        #   - Ensure that `claim["email_verified"]` is set to true.

        CLAIMS.append(claim)
    except Exception as e:
        return f"Invalid token: {e}\n", 400

    envelope = json.loads(request.data.decode("utf-8"))
    payload = base64.b64decode(envelope["message"]["data"])
    MESSAGES.append(payload)
    # Returning any 2xx status indicates successful receipt of the message.
    return "OK", 200

Ruby

post "/pubsub/authenticated-push" do
  halt 400 if params[:token] != PUBSUB_VERIFICATION_TOKEN

  begin
    bearer = request.env["HTTP_AUTHORIZATION"]
    token = /Bearer (.*)/.match(bearer)[1]
    claim = Google::Auth::IDTokens.verify_oidc token, aud: "example.com"

    # IMPORTANT: you should validate claim details not covered by signature
    # and audience verification above, including:
    #   - Ensure that `claim["email"]` is equal to the expected service
    #     account set up in the push subscription settings.
    #   - Ensure that `claim["email_verified"]` is set to true.

    claims.push claim
  rescue Google::Auth::IDTokens::VerificationError => e
    puts "VerificationError: #{e.message}"
    halt 400, "Invalid token"
  end

  message = JSON.parse request.body.read
  payload = Base64.decode64 message["message"]["data"]

  messages.push payload
end

מידע על משתנה הסביבה PUBSUB_VERIFICATION_TOKEN שבו נעשה שימוש בדוגמאות הקוד שלמעלה זמין במאמר כתיבה של הודעות Pub/Sub ומענה להן.

במדריך לכניסה לחשבון Google באתרים יש דוגמאות נוספות לאימות של JWT מסוג Bearer. סקירה כללית רחבה יותר של אסימוני OpenID זמינה במדריך OpenID Connect, כולל רשימה של ספריות לקוח שעוזרות לאמת אסימוני JWT.

אימות משירותים אחרים של Cloud de Confiance by S3NS Google

פונקציות Cloud Run ו-App Engine מאמתות קריאות HTTP מ-Pub/Sub על ידי אימות אסימונים שנוצרו על ידי Pub/Sub. ההגדרה היחידה שנדרשת היא הקצאת תפקידי ה-IAM הנדרשים לחשבון של מבצע הקריאה.

במדריכים ובסרטוני ההדרכה הבאים אפשר למצוא מידע על תרחישי שימוש שונים בשירותים האלה:

Cloud Run

App Engine

פונקציות Cloud Run

  • טריגרים של HTTP: אם אתם מתכוונים להשתמש בבקשות push של Pub/Sub כטריגרים של HTTP לפונקציה, לחשבון השירות של שירות האימות שלכם צריך להיות תפקיד roles/cloudfunctions.invoker כדי להפעיל פונקציה.
  • Google Cloud Pub/Sub Triggers: אם משתמשים בטריגרים של Pub/Sub כדי להפעיל פונקציה, תפקידי ה-IAM וההרשאות מוגדרים אוטומטית.