אימות בין שירותים

בנוסף לאימות משתמשים, יכול להיות שתצטרכו לאפשר לשירותים אחרים ליצור אינטראקציה עם ה-API שלכם. אפליקציות לקוח יכולות להציג למשתמשים בקשה להיכנס לאינטרנט כדי לשלוח את פרטי הכניסה שלהם, אבל צריך גישה אחרת כדי לאפשר תקשורת מאובטחת בין שירותים. בדף הזה מוצגת הגישה המומלצת שלנו להטמעת אימות בין שירותים, ומוצג קוד לדוגמה.

סקירה כללית

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

כדי להטמיע אימות בין שירותים בממשק ה-API ובשירות שמבצע את הקריאה:

  1. יוצרים חשבון שירות ומפתח לשימוש של השירות שקורא ל-API.
  2. מוסיפים תמיכה באימות במסמך OpenAPI של שירות Cloud Endpoints.
  3. מוסיפים קוד לשירות השיחות ש:

    • יוצרת JWT וחותמת עליו באמצעות המפתח הפרטי של חשבון השירות.
    • שליחת ה-JWT החתום בבקשה ל-API.

‫ESP מאמת שההצהרות ב-JWT תואמות להגדרה במסמך OpenAPI לפני שהוא מעביר את הבקשה ל-API. ‫ESP לא בודק הרשאות של Cloud Identity שהענקתם בחשבון השירות.

דרישות מוקדמות

במאמר הזה אנחנו יוצאים מנקודת הנחה שכבר:

יצירת חשבון שירות עם מפתח

אתם צריכים חשבון שירות עם קובץ מפתח פרטי ששירות הקריאה משתמש בו כדי לחתום על ה-JWT. אם יש לכם יותר משירות אחד ששולח בקשות ל-API, אתם יכולים ליצור חשבון שירות אחד שייצג את כל השירותים ששולחים את הבקשות. אם אתם צריכים להבדיל בין השירותים – למשל, אם יש להם הרשאות שונות – אתם יכולים ליצור חשבון שירות ומפתח לכל שירות שקורא ל-API.

בקטע הזה מוסבר איך להשתמש במסוף Google Cloud ובכלי שורת הפקודה gcloud כדי ליצור את חשבון השירות ואת קובץ המפתח הפרטי, ולהקצות לחשבון השירות את התפקיד יוצר האסימונים של חשבון השירות. למידע על שימוש ב-API כדי לבצע את המשימה הזו, אפשר לעיין במאמר יצירה וניהול של חשבונות שירות.

כדי ליצור חשבון שירות ומפתח:

מסוף Google Cloud

  1. יוצרים חשבון שירות:

    1. במסוף Google Cloud , נכנסים לדף יצירת חשבון שירות.

      מעבר לדף Create Service Account

    2. בוחרים את הפרויקט שרוצים להשתמש בו.

    3. כותבים שם בשדה Service account name.

    4. אופציונלי: בשדה תיאור חשבון השירות, מזינים תיאור.

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

    6. לוחצים על סיום.

      לא לסגור את חלון הדפדפן. תשתמשו בו בשלב הבא.

  2. יוצרים מַפְתח לחשבון השירות:

    1. במסוף Google Cloud , לוחצים על כתובת האימייל של חשבון השירות שיצרתם.
    2. לוחצים על Keys.
    3. לוחצים על Add key ואז על Create new key.
    4. לוחצים על יצירה. קובץ JSON שמכיל את המפתח הפרטי של חשבון השירות יורד למחשב.
    5. לוחצים על Close.

gcloud

אפשר להריץ את הפקודות הבאות באמצעות Google Cloud CLI במחשב המקומי או ב-Cloud Shell.

  1. הגדרת חשבון ברירת המחדל ל-gcloud. אם יש לכם יותר מחשבון אחד, חשוב לבחור את החשבון שמשויך ל Google Cloud פרויקט שבו אתם רוצים להשתמש.

    gcloud auth login
    
  2. הצגת מזהי הפרויקטים של Google Cloud הפרויקטים שלכם.

    gcloud projects list
    
  3. מגדירים את פרויקט ברירת המחדל. מחליפים את PROJECT_ID במזהה הפרויקט שרוצים להשתמש בו. Google Cloud

    gcloud config set project PROJECT_ID
  4. יוצרים חשבון שירות. מחליפים את SA_NAME ואת SA_DISPLAY_NAME בשם ובשם לתצוגה שרוצים להשתמש בהם.

    gcloud iam service-accounts create SA_NAME \
      --display-name "SA_DISPLAY_NAME"
  5. מציגים את כתובת האימייל של חשבון השירות שיצרתם.

    gcloud iam service-accounts list
    
  6. מוסיפים את התפקיד יצירת אסימונים בחשבון שירות. מחליפים את SA_EMAIL_ADDRESS בכתובת האימייל של חשבון השירות.

    gcloud projects add-iam-policy-binding PROJECT_ID \
      --member serviceAccount:SA_EMAIL_ADDRESS \
      --role roles/iam.serviceAccountTokenCreator
  7. יוצרים קובץ מפתח של חשבון שירות בספריית העבודה הנוכחית. מחליפים את FILE_NAME בשם שרוצים לתת לקובץ המפתח. כברירת מחדל, הפקודה gcloud יוצרת קובץ JSON.

    gcloud iam service-accounts keys create FILE_NAME.json \
      --iam-account SA_EMAIL_ADDRESS

מידע נוסף על הפקודות הקודמות מופיע בgcloudחומר העזר.

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

הגדרת ה-API כך שיתמוך באימות

כדי להפעיל אימות של חשבון שירות בשירותים שקוראים לשער, צריך לשנות את אובייקטי האבטחה במסמך OpenAPI כדי ש-ESP יאמת את הטענות ב-JWT החתום. השינויים יהיו שונים בהתאם לגרסה של מפרט OpenAPI שבה נעשה שימוש.

OpenAPI 2.0

  1. מוסיפים את חשבון השירות כגורם מנפיק במפרט OpenAPI:
    securityDefinitions:
        DEFINITION_NAME:
          authorizationUrl: ""
          flow: "implicit"
          type: "oauth2"
          x-google-issuer: "SA_EMAIL_ADDRESS"
          x-google-jwks_uri: "https://www.googleapis.com/robot/v1/metadata/x509/SA_EMAIL_ADDRESS"
      
    • מחליפים את DEFINITION_NAME במחרוזת שמזהה את הגדרת האבטחה הזו. אפשר להחליף אותו בשם של חשבון השירות או בשם שמזהה את השירות שמבצע את הקריאה.
    • מחליפים את SA_EMAIL_ADDRESS בכתובת האימייל של חשבון השירות.
    • אפשר להגדיר כמה הגדרות אבטחה במפרט OpenAPI, אבל לכל הגדרה צריך להיות x-google-issuerשםx-google-issuer שונה. אם יצרתם חשבונות שירות נפרדים לכל שירות שיחות, תוכלו ליצור הגדרת אבטחה לכל חשבון שירות, למשל:
      securityDefinitions:
          service-1:
            authorizationUrl: ""
            flow: "implicit"
            type: "oauth2"
            x-google-issuer: "service-1@example-project-12345.iam.gserviceaccount.com"
            x-google-jwks_uri: "https://www.googleapis.com/robot/v1/metadata/x509/service-1@example-project-12345.iam.gserviceaccount.com"
          service-2:
            authorizationUrl: ""
            flow: "implicit"
            type: "oauth2"
            x-google-issuer: "service-2@example-project-12345.iam.gserviceaccount.com"
            x-google-jwks_uri: "https://www.googleapis.com/robot/v1/metadata/x509/service-2@example-project-12345.iam.gserviceaccount.com"
  2. אפשר גם להוסיף x-google-audiences לקטע securityDefinitions. אם לא מוסיפים את x-google-audiences,‏ ESP דורש שהתביעה "aud" (קהל) ב-JWT תהיה בפורמט https://SERVICE_NAME, כאשר SERVICE_NAME הוא השם של שירות ה-ESP שהגדרתם בשדה host במסמך OpenAPI.
  3. מוסיפים קטע security ברמה העליונה של הקובץ (לא מוזח או מקונן) כדי להחיל אותו על ה-API כולו, או ברמת השיטה כדי להחיל אותו על שיטה ספציפית. אם משתמשים בקטעי security ברמת ה-API וברמת השיטה, ההגדרות ברמת השיטה מבטלות את ההגדרות ברמת ה-API.
    security:
      - DEFINITION_NAME: []
    • מחליפים את DEFINITION_NAME בשם שבו השתמשתם בקטע securityDefinitions.
    • אם יש יותר מהגדרה אחת בקטע securityDefinitions, מוסיפים אותן בקטע security. לדוגמה:
      security:
        - service-1: []
        - service-2: []
  4. מבצעים פריסה של מפרט OpenAPI מעודכן. לפני ש-ESP מעביר בקשה ל-API שלכם, הוא בודק את הדברים הבאים:
    • החתימה של ה-JWT באמצעות המפתח הציבורי, שנמצא ב-URI שצוין בשדה x-google-jwks_uri במפרט OpenAPI.
    • התביעה "iss"(המנפיק) ב-JWT תואמת לערך שצוין בשדה x-google-issuer.
    • התביעה "aud"(קהל) ב-JWT מכילה את שם שירות ה-ESP או תואמת לאחד מהערכים שציינתם בשדה x-google-audiences.
    • שהתוקף של הטוקן לא פג באמצעות הטענה "exp"(זמן התפוגה).

‫OpenAPI 3.x

  1. מוסיפים את חשבון השירות כגורם מנפיק במפרט OpenAPI:
    components:
      securitySchemes:
        SCHEME_NAME:
          type: oauth2
          flows:
           implicit:
             authorizationUrl: ""
             scopes: {}
          x-google-auth:
            issuer: SA_EMAIL_ADDRESS
            jwksUri: https://www.googleapis.com/robot/v1/metadata/x509/SA_EMAIL_ADDRESS
            audiences:
              - 848149964201.apps.googleusercontent.com
              - 841077041629.apps.googleusercontent.com
            jwtLocations:
              - header: Authorization
                valuePrefix: "Bearer "
    security:
      - SCHEME_NAME: []
    • מחליפים את SCHEME_NAME במחרוזת שמזהה את תוכנית האבטחה הזו. אפשר להחליף אותו בשם של חשבון השירות או בשם שמזהה את השירות שמבצע את הקריאה.
    • מחליפים את SA_EMAIL_ADDRESS בכתובת האימייל של חשבון השירות.
    • אפשר להגדיר כמה תוכניות אבטחה במפרט OpenAPI, אבל לכל הגדרה צריך להיות issuer שונה. אם יצרתם חשבונות שירות נפרדים לכל שירות שיחות, תוכלו ליצור הגדרת אבטחה לכל חשבון שירות, למשל:
      components:
        securitySchemes:
          service-1:
            type: oauth2
            flows:
             implicit:
               authorizationUrl: ""
               scopes: {}
            x-google-auth:
              issuer: "service-1@example-project-12345.iam.gserviceaccount.com"
              jwksUri: https://www.googleapis.com/robot/v1/metadata/x509/service-1@example-project-12345.iam.gserviceaccount.com
              jwtLocations:
                - header: Authorization
                  valuePrefix: "Bearer "
          service-2:
            type: oauth2
            flows:
             implicit:
               authorizationUrl: ""
               scopes: {}
            x-google-auth:
              issuer: "service-2@example-project-12345.iam.gserviceaccount.com"
              jwksUri: "https://www.googleapis.com/robot/v1/metadata/x509/service-2@example-project-12345.iam.gserviceaccount.com"
              jwtLocations:
                - header: Authorization
                  valuePrefix: "Bearer "
  2. אפשר גם להוסיף audiences לקטע securitySchemes. אם לא מוסיפים את audiences,‏ ESP דורש שהתביעה "aud" (קהל) ב-JWT תהיה בפורמט https://SERVICE_NAME, כאשר SERVICE_NAME הוא השם של שירות ה-ESP שהגדרתם בשדה host במסמך OpenAPI.
  3. מוסיפים קטע security ברמה העליונה של הקובץ (לא מוזח או מקונן) כדי להחיל אותו על ה-API כולו, או ברמת השיטה כדי להחיל אותו על שיטה ספציפית. אם משתמשים בקטעי security ברמת ה-API וברמת השיטה, ההגדרות ברמת השיטה מבטלות את ההגדרות ברמת ה-API.
    security:
      - SCHEME_NAME: []
    • מחליפים את SCHEME_NAME בשם שבו השתמשתם בקטע securitySchemes.
    • אם יש יותר מהגדרה אחת בקטע securitySchemes, מוסיפים אותן בקטע security. לדוגמה:
      security:
        - service-1: []
        - service-2: []
  4. מבצעים פריסה של מפרט OpenAPI מעודכן. לפני ש-ESP מעביר בקשה ל-API שלכם, הוא בודק את הדברים הבאים:
    • החתימה של ה-JWT באמצעות המפתח הציבורי, שנמצא ב-URI שצוין בשדה jwksUri במפרט OpenAPI.
    • התביעה "iss"(המנפיק) ב-JWT תואמת לערך שצוין בשדה issuer.
    • שהטענה "aud"(קהל) ב-JWT מכילה את שם שירות ה-ESP שלכם או תואמת לאחד מהערכים שציינתם בשדה audiences.
    • שהתוקף של הטוקן לא פג באמצעות הטענה "exp"(זמן התפוגה).

שליחת בקשה מאומתת ל-Endpoints API

כדי לשלוח בקשה מאומתת, השירות שקורא ל-API שולח JWT שחתום על ידי חשבון השירות שצוין במסמך OpenAPI. שירות השיחות צריך:

  1. יוצרים JWT וחותמים עליו באמצעות המפתח הפרטי של חשבון השירות.
  2. שולחים את ה-JWT החתום בבקשה ל-API.

בדוגמת הקוד הבאה מוצג התהליך הזה עבור שפות נבחרות. כדי לשלוח בקשה מאומתת בשפות אחרות, אפשר לעיין בjwt.io כדי לראות רשימה של ספריות נתמכות.

  1. בשירות הקורא, מוסיפים את הפונקציה הבאה ומעבירים לה את הפרמטרים הבאים:
    Java
    • saKeyfile: הנתיב המלא לקובץ המפתח הפרטי של חשבון השירות.
    • saEmail: כתובת האימייל של חשבון השירות.
    • audience: אם הוספתם את השדה x-google-audiences למסמך OpenAPI, צריך להגדיר את audience לאחד מהערכים שציינתם עבור x-google-audiences. אחרת, מגדירים את audience ל-https://SERVICE_NAME, כאשר SERVICE_NAME הוא שם השירות של Endpoints.
    • expiryLength: זמן התפוגה של JWT, בשניות.
    Python
    • sa_keyfile: הנתיב המלא לקובץ המפתח הפרטי של חשבון השירות.
    • sa_email: כתובת האימייל של חשבון השירות.
    • audience: אם הוספתם את השדה x-google-audiences למסמך OpenAPI, צריך להגדיר את audience לאחד מהערכים שציינתם עבור x-google-audiences. אחרת, מגדירים את audience ל-https://SERVICE_NAME, כאשר SERVICE_NAME הוא שם השירות של Endpoints.
    • expiry_length: זמן התפוגה של JWT, בשניות.
    Go
    • saKeyfile: הנתיב המלא לקובץ המפתח הפרטי של חשבון השירות.
    • saEmail: כתובת האימייל של חשבון השירות.
    • audience: אם הוספתם את השדה x-google-audiences למסמך OpenAPI, צריך להגדיר את audience לאחד מהערכים שציינתם עבור x-google-audiences. אחרת, מגדירים את audience ל-https://SERVICE_NAME, כאשר SERVICE_NAME הוא שם השירות של Endpoints.
    • expiryLength: זמן התפוגה של JWT, בשניות.

    הפונקציה יוצרת JWT, חותמת עליו באמצעות קובץ המפתח הפרטי ומחזירה את ה-JWT החתום.

    Java
    /**
     * Generates a signed JSON Web Token using a Google API Service Account
     * utilizes com.auth0.jwt.
     */
    public static String generateJwt(final String saKeyfile, final String saEmail,
        final String audience, final int expiryLength)
        throws FileNotFoundException, IOException {
    
      Date now = new Date();
      Date expTime = new Date(System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(expiryLength));
    
      // Build the JWT payload
      JWTCreator.Builder token = JWT.create()
          .withIssuedAt(now)
          // Expires after 'expiryLength' seconds
          .withExpiresAt(expTime)
          // Must match 'issuer' in the security configuration in your
          // swagger spec (e.g. service account email)
          .withIssuer(saEmail)
          // Must be either your Endpoints service name, or match the value
          // specified as the 'x-google-audience' in the OpenAPI document
          .withAudience(audience)
          // Subject and email should match the service account's email
          .withSubject(saEmail)
          .withClaim("email", saEmail);
    
      // Sign the JWT with a service account
      FileInputStream stream = new FileInputStream(saKeyfile);
      ServiceAccountCredentials cred = ServiceAccountCredentials.fromStream(stream);
      RSAPrivateKey key = (RSAPrivateKey) cred.getPrivateKey();
      Algorithm algorithm = Algorithm.RSA256(null, key);
      return token.sign(algorithm);
    }
    Python
    def generate_jwt(
        sa_keyfile,
        sa_email="account@project-id.iam.gserviceaccount.com",
        audience="your-service-name",
        expiry_length=3600,
    ):
        """Generates a signed JSON Web Token using a Google API Service Account."""
    
        now = int(time.time())
    
        # build payload
        payload = {
            "iat": now,
            # expires after 'expiry_length' seconds.
            "exp": now + expiry_length,
            # iss must match 'issuer' in the security configuration in your
            # swagger spec (e.g. service account email). It can be any string.
            "iss": sa_email,
            # aud must be either your Endpoints service name, or match the value
            # specified as the 'x-google-audience' in the OpenAPI document.
            "aud": audience,
            # sub and email should match the service account's email address
            "sub": sa_email,
            "email": sa_email,
        }
    
        # sign with keyfile
        signer = google.auth.crypt.RSASigner.from_service_account_file(sa_keyfile)
        jwt = google.auth.jwt.encode(signer, payload)
    
        return jwt
    
    
    Go
    
    // generateJWT creates a signed JSON Web Token using a Google API Service Account.
    func generateJWT(saKeyfile, saEmail, audience string, expiryLength int64) (string, error) {
    	now := time.Now().Unix()
    
    	// Build the JWT payload.
    	jwt := &jws.ClaimSet{
    		Iat: now,
    		// expires after 'expiryLength' seconds.
    		Exp: now + expiryLength,
    		// Iss must match 'issuer' in the security configuration in your
    		// swagger spec (e.g. service account email). It can be any string.
    		Iss: saEmail,
    		// Aud must be either your Endpoints service name, or match the value
    		// specified as the 'x-google-audience' in the OpenAPI document.
    		Aud: audience,
    		// Sub and Email should match the service account's email address.
    		Sub:           saEmail,
    		PrivateClaims: map[string]interface{}{"email": saEmail},
    	}
    	jwsHeader := &jws.Header{
    		Algorithm: "RS256",
    		Typ:       "JWT",
    	}
    
    	// Extract the RSA private key from the service account keyfile.
    	sa, err := os.ReadFile(saKeyfile)
    	if err != nil {
    		return "", fmt.Errorf("could not read service account file: %w", err)
    	}
    	conf, err := google.JWTConfigFromJSON(sa)
    	if err != nil {
    		return "", fmt.Errorf("could not parse service account JSON: %w", err)
    	}
    	block, _ := pem.Decode(conf.PrivateKey)
    	parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
    	if err != nil {
    		return "", fmt.Errorf("private key parse error: %w", err)
    	}
    	rsaKey, ok := parsedKey.(*rsa.PrivateKey)
    	// Sign the JWT with the service account's private key.
    	if !ok {
    		return "", errors.New("private key failed rsa.PrivateKey type assertion")
    	}
    	return jws.Encode(jwsHeader, jwt, rsaKey)
    }
    
  2. בשירות שמבצע את הקריאה, מוסיפים את הפונקציה הבאה כדי לשלוח את ה-JWT החתום בכותרת Authorization: Bearer בבקשה ל-API:
    Java
    /**
     * Makes an authorized request to the endpoint.
     */
    public static String makeJwtRequest(final String signedJwt, final URL url)
        throws IOException, ProtocolException {
    
      HttpURLConnection con = (HttpURLConnection) url.openConnection();
      con.setRequestMethod("GET");
      con.setRequestProperty("Content-Type", "application/json");
      con.setRequestProperty("Authorization", "Bearer " + signedJwt);
    
      InputStreamReader reader = new InputStreamReader(con.getInputStream());
      BufferedReader buffReader = new BufferedReader(reader);
    
      String line;
      StringBuilder result = new StringBuilder();
      while ((line = buffReader.readLine()) != null) {
        result.append(line);
      }
      buffReader.close();
      return result.toString();
    }
    Python
    def make_jwt_request(signed_jwt, url="https://your-endpoint.com"):
        """Makes an authorized request to the endpoint"""
        headers = {
            "Authorization": "Bearer {}".format(signed_jwt.decode("utf-8")),
            "content-type": "application/json",
        }
        response = requests.get(url, headers=headers)
        print(response.status_code, response.content)
        response.raise_for_status()
    
    
    Go
    
    // makeJWTRequest sends an authorized request to your deployed endpoint.
    func makeJWTRequest(signedJWT, url string) (string, error) {
    	client := &http.Client{
    		Timeout: 10 * time.Second,
    	}
    
    	req, err := http.NewRequest("GET", url, nil)
    	if err != nil {
    		return "", fmt.Errorf("failed to create HTTP request: %w", err)
    	}
    	req.Header.Add("Authorization", "Bearer "+signedJWT)
    	req.Header.Add("content-type", "application/json")
    
    	response, err := client.Do(req)
    	if err != nil {
    		return "", fmt.Errorf("HTTP request failed: %w", err)
    	}
    	defer response.Body.Close()
    	responseData, err := io.ReadAll(response.Body)
    	if err != nil {
    		return "", fmt.Errorf("failed to parse HTTP response: %w", err)
    	}
    	return string(responseData), nil
    }
    

כששולחים בקשה באמצעות JWT, מטעמי אבטחה מומלץ להוסיף את אסימון האימות לכותרת Authorization: Bearer. לדוגמה:

curl --request POST \
  --header "Authorization: Bearer ${TOKEN}" \
  "${ENDPOINTS_HOST}/echo"

כאשר ENDPOINTS_HOST ו-TOKEN הם משתני סביבה שמכילים את שם המארח של ה-API ואת טוקן האימות, בהתאמה.

קבלת תוצאות מאומתות ב-API

בדרך כלל ספקי ESP מעבירים את כל הכותרות שהם מקבלים. עם זאת, הוא מחליף את הכותרת המקורית Authorization כשכתובת ה-Backend מצוינת על ידי x-google-backend במפרט OpenAPI או על ידי BackendRule בהגדרת שירות gRPC.

ספק ה-ESP ישלח את תוצאת האימות ב-X-Endpoint-API-UserInfo ל-API של השרת העורפי. מומלץ להשתמש בכותרת הזו במקום בכותרת המקורית Authorization. הכותרת הזו היא מחרוזת שbase64urlמקודדת אובייקט JSON. פורמט אובייקט ה-JSON שונה בין ESPv2 לבין ESP. ב-ESPv2, אובייקט ה-JSON הוא בדיוק המטען הייעודי (payload) המקורי של ה-JWT. ב-ESP, אובייקט ה-JSON משתמש בשמות שדות שונים ומציב את מטען ה-JWT המקורי בשדה claims. מידע נוסף על הפורמט זמין במאמר בנושא טיפול ב-JWT בשירות לקצה העורפי.

המאמרים הבאים