go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/auth/service/service.go (about) 1 // Copyright 2015 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package service 16 17 import ( 18 "bytes" 19 "compress/zlib" 20 "context" 21 "crypto/sha256" 22 "encoding/base64" 23 "encoding/hex" 24 "encoding/json" 25 "fmt" 26 "io" 27 "time" 28 29 "google.golang.org/protobuf/proto" 30 31 "go.chromium.org/luci/common/logging" 32 33 "go.chromium.org/luci/server/auth/authdb" 34 "go.chromium.org/luci/server/auth/internal" 35 "go.chromium.org/luci/server/auth/service/protocol" 36 "go.chromium.org/luci/server/auth/signing" 37 ) 38 39 // oauthScopes is OAuth scopes required for using AuthService API (including 40 // PubSub part). 41 var oauthScopes = []string{ 42 "https://www.googleapis.com/auth/userinfo.email", 43 "https://www.googleapis.com/auth/pubsub", 44 } 45 46 // Notification represents a notification about AuthDB change. Must be acked 47 // once processed. 48 type Notification struct { 49 Revision int64 // new auth DB revision 50 51 service *AuthService // parent service 52 subscription string // subscription name the change was pulled from 53 ackIDs []string // IDs of all messages to ack 54 } 55 56 // Acknowledge tells PubSub to stop redelivering this notification. 57 func (n *Notification) Acknowledge(ctx context.Context) error { 58 if n.service != nil { // may be nil if Notification is generated in tests 59 return n.service.ackMessages(ctx, n.subscription, n.ackIDs) 60 } 61 return nil 62 } 63 64 // pubSubMessage is received via PubSub when AuthDB changes. 65 type pubSubMessage struct { 66 AckID string `json:"ackId"` // set only when pulling 67 Message struct { 68 MessageID string `json:"messageId"` 69 Data string `json:"data"` 70 Attributes map[string]string `json:"attributes"` 71 } `json:"message"` 72 } 73 74 // Snapshot contains AuthDB proto message (all user groups and other information 75 // received from auth_service), along with its revision number, timestamp of 76 // when it was created, and URL of a service it was fetched from. 77 type Snapshot struct { 78 AuthDB *protocol.AuthDB 79 AuthServiceURL string 80 Rev int64 81 Created time.Time 82 } 83 84 // AuthDBAccess describes how an authorized reader can access the AuthDB. 85 // 86 // See RequestAccess. 87 type AuthDBAccess struct { 88 NotificationTopic string // pubsub topic name "project/<project>/topics/<topic>" 89 StorageDumpPath string // GCS storage path "<bucket>/<object>", may be empty 90 } 91 92 // AuthService represents API exposed by auth_service. 93 // 94 // It is a fairy low-level API, you must have good reasons for using it. 95 type AuthService struct { 96 // URL is root URL (with protocol) of auth_service (e.g. "https://<host>"). 97 URL string 98 // OAuthScopes is scopes to use for authentication (or nil for defaults). 99 OAuthScopes []string 100 101 // pubSubURLRoot is root URL of PubSub service. Mocked in tests. 102 pubSubURLRoot string 103 } 104 105 // pubSubURL returns full URL to pubsub API endpoint. 106 func (s *AuthService) pubSubURL(path string) string { 107 if s.pubSubURLRoot != "" { 108 return s.pubSubURLRoot + path 109 } 110 return "https://pubsub.googleapis.com/v1/" + path 111 } 112 113 // oauthScopes returns a list of OAuth scopes to use for authentication. 114 func (s *AuthService) oauthScopes() []string { 115 if s.OAuthScopes != nil { 116 return s.OAuthScopes 117 } 118 return oauthScopes 119 } 120 121 // RequestAccess asks Auth Service to grant the caller (us) access to the AuthDB 122 // change notifications PubSub topic and AuthDB GCS dump. 123 // 124 // This works only if the caller is in "auth-trusted-services" group. As soon 125 // as the caller is removed from this group, the access is revoked. 126 func (s *AuthService) RequestAccess(ctx context.Context) (*AuthDBAccess, error) { 127 // See appengine/auth_service/handlers_frontend.py. 128 var response struct { 129 Topic string `json:"topic"` 130 GS struct { 131 AuthDBGSPath string `json:"auth_db_gs_path"` 132 } `json:"gs"` 133 } 134 req := internal.Request{ 135 Method: "POST", 136 URL: s.URL + "/auth_service/api/v1/authdb/subscription/authorization", 137 Scopes: s.oauthScopes(), 138 Body: map[string]string{}, 139 Out: &response, 140 } 141 if err := req.Do(ctx); err != nil { 142 return nil, err 143 } 144 return &AuthDBAccess{ 145 NotificationTopic: response.Topic, 146 StorageDumpPath: response.GS.AuthDBGSPath, // may be "" 147 }, nil 148 } 149 150 // EnsureSubscription creates a new subscription to AuthDB change notifications 151 // topic or changes its pushURL if it already exists. `subscription` is full 152 // subscription name e.g. "projects/<projectid>/subscriptions/<subid>". Name of 153 // the topic is fetched from the auth service. Returns nil if such subscription 154 // already exists. 155 func (s *AuthService) EnsureSubscription(ctx context.Context, subscription, pushURL string) error { 156 // Subscription already exists? 157 var existing struct { 158 Error struct { 159 Code int `json:"code"` 160 } `json:"error"` 161 PushConfig struct { 162 PushEndpoint string `json:"pushEndpoint"` 163 } `json:"pushConfig"` 164 } 165 req := internal.Request{ 166 Method: "GET", 167 URL: s.pubSubURL(subscription), 168 Scopes: s.oauthScopes(), 169 Out: &existing, 170 } 171 err := req.Do(ctx) 172 if err != nil && existing.Error.Code != 404 { 173 return err 174 } 175 176 // Create a new subscription if existing is missing. 177 if err != nil { 178 // Make sure caller has access to the PubSub topic. 179 access, err := s.RequestAccess(ctx) 180 if err != nil { 181 return err 182 } 183 // Create the subscription. 184 var config struct { 185 Topic string `json:"topic"` 186 PushConfig struct { 187 PushEndpoint string `json:"pushEndpoint"` 188 } `json:"pushConfig"` 189 AckDeadlineSeconds int `json:"ackDeadlineSeconds"` 190 } 191 config.Topic = access.NotificationTopic 192 config.PushConfig.PushEndpoint = pushURL 193 config.AckDeadlineSeconds = 60 194 req = internal.Request{ 195 Method: "PUT", 196 URL: s.pubSubURL(subscription), 197 Scopes: s.oauthScopes(), 198 Body: &config, 199 } 200 return req.Do(ctx) 201 } 202 203 // Is existing subscription configured correctly already? 204 if existing.PushConfig.PushEndpoint == pushURL { 205 return nil 206 } 207 208 // Reconfigure existing subscription. 209 var request struct { 210 PushConfig struct { 211 PushEndpoint string `json:"pushEndpoint"` 212 } `json:"pushConfig"` 213 } 214 request.PushConfig.PushEndpoint = pushURL 215 216 req = internal.Request{ 217 Method: "POST", 218 URL: s.pubSubURL(subscription + ":modifyPushConfig"), 219 Scopes: s.oauthScopes(), 220 Body: &request, 221 } 222 return req.Do(ctx) 223 } 224 225 // DeleteSubscription removes PubSub subscription if it exists. 226 func (s *AuthService) DeleteSubscription(ctx context.Context, subscription string) error { 227 var reply struct { 228 Error struct { 229 Code int `json:"code"` 230 } `json:"error"` 231 } 232 req := internal.Request{ 233 Method: "DELETE", 234 URL: s.pubSubURL(subscription), 235 Scopes: s.oauthScopes(), 236 Out: &reply, 237 } 238 if err := req.Do(ctx); err != nil && reply.Error.Code != 404 { 239 return err 240 } 241 return nil 242 } 243 244 // PullPubSub pulls pending PubSub messages (from subscription created 245 // previously by EnsureSubscription), authenticates them, and converts 246 // them into Notification object. Returns (nil, nil) if no pending messages. 247 // Does not wait for messages to arrive. 248 func (s *AuthService) PullPubSub(ctx context.Context, subscription string) (*Notification, error) { 249 request := struct { 250 ReturnImmediately bool `json:"returnImmediately"` 251 MaxMessages int `json:"maxMessages"` 252 }{true, 1000} 253 254 response := struct { 255 ReceivedMessages []pubSubMessage `json:"receivedMessages"` 256 }{} 257 258 req := internal.Request{ 259 Method: "POST", 260 URL: s.pubSubURL(subscription + ":pull"), 261 Scopes: s.oauthScopes(), 262 Body: &request, 263 Out: &response, 264 } 265 if err := req.Do(ctx); err != nil { 266 return nil, err 267 } 268 if len(response.ReceivedMessages) == 0 { 269 return nil, nil 270 } 271 272 logging.Infof(ctx, "Received PubSub notification: %d", len(response.ReceivedMessages)) 273 274 // Grab certs to verify signatures on PubSub messages. 275 certs, err := signing.FetchCertificatesFromLUCIService(ctx, s.URL) 276 if err != nil { 277 return nil, err 278 } 279 280 // Find maximum AuthDB revision among all verified messages. Invalid messages 281 // are skipped (with logging). 282 maxRev := int64(-1) 283 ackIDs := ([]string)(nil) 284 for _, msg := range response.ReceivedMessages { 285 if msg.AckID != "" { 286 ackIDs = append(ackIDs, msg.AckID) 287 } 288 notify, err := s.decodeMessage(&msg, certs) 289 if err != nil { 290 logging.Warningf(ctx, "Bad signature on message %s - %s", msg.Message.MessageID, err) 291 continue 292 } 293 if notify.GetRevision().GetAuthDbRev() > maxRev { 294 maxRev = notify.GetRevision().GetAuthDbRev() 295 } 296 } 297 298 // All message are invalid and skipped? Try to acknowledge them to remove 299 // them from the queue, but don't error if it fails. 300 if maxRev == -1 { 301 err := s.ackMessages(ctx, subscription, ackIDs) 302 if err != nil { 303 logging.Warningf(ctx, "Failed to ack some PubSub messages (%v) - %s", ackIDs, err) 304 } 305 return nil, nil 306 } 307 308 logging.Infof(ctx, "Auth service notifies us that the most recent AuthDB revision is %d", maxRev) 309 return &Notification{ 310 Revision: maxRev, 311 service: s, 312 subscription: subscription, 313 ackIDs: ackIDs, 314 }, nil 315 } 316 317 // ProcessPubSubPush handles incoming PubSub push notification. `body` is 318 // the entire body of the push HTTP request. Invalid messages are silently 319 // skipped by returning nil error (to avoid redelivery). The error is still 320 // logged though. 321 func (s *AuthService) ProcessPubSubPush(ctx context.Context, body []byte) (*Notification, error) { 322 msg := pubSubMessage{} 323 if err := json.Unmarshal(body, &msg); err != nil { 324 logging.Errorf(ctx, "auth: bad PubSub notification, not JSON - %s", err) 325 return nil, nil 326 } 327 328 // It's fine to return error here. Certificate fetch can fail due to bad 329 // connectivity and we need a retry. 330 certs, err := signing.FetchCertificatesFromLUCIService(ctx, s.URL) 331 if err != nil { 332 return nil, err 333 } 334 335 notify, err := s.decodeMessage(&msg, certs) 336 if err != nil { 337 logging.Errorf(ctx, "auth: bad PubSub notification - %s", err) 338 return nil, nil 339 } 340 341 return &Notification{ 342 Revision: notify.GetRevision().GetAuthDbRev(), 343 service: s, 344 }, nil 345 } 346 347 /// 348 349 // decodeMessage checks that the message payload was signed by Auth service key 350 // and deserializes it. 351 func (s *AuthService) decodeMessage(m *pubSubMessage, certs *signing.PublicCertificates) (*protocol.ChangeNotification, error) { 352 // Key name used to sign. 353 keyName := m.Message.Attributes["X-AuthDB-SigKey-v1"] 354 if keyName == "" { 355 return nil, fmt.Errorf("X-AuthDB-SigKey-v1 attribute is not set") 356 } 357 358 // The signature. 359 sigBase64 := m.Message.Attributes["X-AuthDB-SigVal-v1"] 360 if sigBase64 == "" { 361 return nil, fmt.Errorf("X-AuthDB-SigVal-v1 attribute is not set") 362 } 363 sig, err := base64.StdEncoding.DecodeString(sigBase64) 364 if err != nil { 365 return nil, fmt.Errorf("the message signature is not valid base64 - %s", err) 366 } 367 368 // Message body. 369 body, err := base64.StdEncoding.DecodeString(m.Message.Data) 370 if err != nil { 371 return nil, fmt.Errorf("the message body is not valid base64 - %s", err) 372 } 373 374 // Validate. 375 if err := certs.CheckSignature(keyName, body, sig); err != nil { 376 return nil, err 377 } 378 379 // Deserialize. 380 out := protocol.ChangeNotification{} 381 if err := proto.Unmarshal(body, &out); err != nil { 382 return nil, err 383 } 384 return &out, nil 385 } 386 387 // ackMessages acknowledges processing of pubsub messages. 388 func (s *AuthService) ackMessages(ctx context.Context, subscription string, ackIDs []string) error { 389 if len(ackIDs) == 0 { 390 return nil 391 } 392 request := struct { 393 AckIDs []string `json:"ackIds"` 394 }{ackIDs} 395 req := internal.Request{ 396 Method: "POST", 397 URL: s.pubSubURL(subscription + ":acknowledge"), 398 Scopes: s.oauthScopes(), 399 Body: &request, 400 } 401 return req.Do(ctx) 402 } 403 404 // GetLatestSnapshotRevision fetches revision number of the latest AuthDB 405 // snapshot. 406 func (s *AuthService) GetLatestSnapshotRevision(ctx context.Context) (int64, error) { 407 var out struct { 408 Snapshot struct { 409 Rev int64 `json:"auth_db_rev"` 410 } `json:"snapshot"` 411 } 412 req := internal.Request{ 413 Method: "GET", 414 URL: s.URL + "/auth_service/api/v1/authdb/revisions/latest?skip_body=1", 415 Scopes: s.oauthScopes(), 416 Out: &out, 417 } 418 if err := req.Do(ctx); err != nil { 419 return 0, err 420 } 421 return out.Snapshot.Rev, nil 422 } 423 424 // GetSnapshot fetches AuthDB snapshot at given revision, unpacks and 425 // validates it. 426 func (s *AuthService) GetSnapshot(ctx context.Context, rev int64) (*Snapshot, error) { 427 // Fetch. 428 var out struct { 429 Snapshot struct { 430 Rev int64 `json:"auth_db_rev"` 431 SHA256 string `json:"sha256"` 432 Created int64 `json:"created_ts"` 433 DeflatedBody string `json:"deflated_body"` 434 } `json:"snapshot"` 435 } 436 req := internal.Request{ 437 Method: "GET", 438 URL: fmt.Sprintf("%s/auth_service/api/v1/authdb/revisions/%d", s.URL, rev), 439 Scopes: s.oauthScopes(), 440 Out: &out, 441 } 442 if err := req.Do(ctx); err != nil { 443 return nil, err 444 } 445 if out.Snapshot.Rev != rev { 446 return nil, fmt.Errorf( 447 "auth: unexpected revision %d of AuthDB snapshot, expecting %d", out.Snapshot.Rev, rev) 448 } 449 450 // Decode base64. 451 deflated, err := base64.StdEncoding.DecodeString(out.Snapshot.DeflatedBody) 452 if err != nil { 453 return nil, err 454 } 455 456 // Inflate and calculate SHA256 of inflated blob. 457 r, err := zlib.NewReader(bytes.NewReader(deflated)) 458 if err != nil { 459 return nil, err 460 } 461 defer r.Close() 462 blob := bytes.Buffer{} 463 hash := sha256.New() 464 if _, err := io.Copy(io.MultiWriter(&blob, hash), r); err != nil { 465 return nil, err 466 } 467 468 // Make sure SHA256 is correct. 469 digest := hex.EncodeToString(hash.Sum(nil)) 470 if digest != out.Snapshot.SHA256 { 471 return nil, fmt.Errorf("auth: wrong SHA256 digest of AuthDB snapshot at rev %d", rev) 472 } 473 474 // Unmarshal. 475 msg := protocol.ReplicationPushRequest{} 476 if err := proto.Unmarshal(blob.Bytes(), &msg); err != nil { 477 return nil, err 478 } 479 revMsg := msg.GetRevision() 480 if revMsg == nil || revMsg.GetAuthDbRev() != rev { 481 return nil, fmt.Errorf("auth: bad 'revision' field in proto message (%v)", revMsg) 482 } 483 484 // Log some stats. 485 logging.Infof( 486 ctx, "auth: fetched AuthDB snapshot at rev %d (inflated size is %.1f Kb, deflated size is %.1f Kb)", 487 rev, float32(len(blob.Bytes()))/1024, float32(len(deflated))/1024) 488 logging.Infof( 489 ctx, "auth: AuthDB snapshot generated by %s (using components.auth v%s)", 490 revMsg.GetPrimaryId(), msg.GetAuthCodeVersion()) 491 492 // Validate AuthDB by constructing SnapshotDB from it (with validation). 493 authDB := msg.GetAuthDb() 494 if authDB == nil { 495 return nil, fmt.Errorf("auth: 'auth_db' field is missing in proto message (%v)", &msg) 496 } 497 if _, err := authdb.NewSnapshotDB(authDB, s.URL, rev, true); err != nil { 498 return nil, err 499 } 500 return &Snapshot{ 501 AuthDB: authDB, 502 AuthServiceURL: s.URL, 503 Rev: rev, 504 Created: time.Unix(0, out.Snapshot.Created*1000), 505 }, nil 506 } 507 508 // DeflateAuthDB serializes AuthDB to byte buffer and compresses it with zlib. 509 func DeflateAuthDB(msg *protocol.AuthDB) ([]byte, error) { 510 blob, err := proto.Marshal(msg) 511 if err != nil { 512 return nil, err 513 } 514 out := bytes.Buffer{} 515 writer := zlib.NewWriter(&out) 516 _, err1 := io.Copy(writer, bytes.NewReader(blob)) 517 err2 := writer.Close() 518 if err1 != nil { 519 return nil, err1 520 } 521 if err2 != nil { 522 return nil, err2 523 } 524 return out.Bytes(), nil 525 } 526 527 // InflateAuthDB is reverse of DeflateAuthDB. It decompresses and deserializes 528 // AuthDB message. 529 func InflateAuthDB(blob []byte) (*protocol.AuthDB, error) { 530 reader, err := zlib.NewReader(bytes.NewReader(blob)) 531 if err != nil { 532 return nil, err 533 } 534 defer reader.Close() 535 inflated := bytes.Buffer{} 536 if _, err := io.Copy(&inflated, reader); err != nil { 537 return nil, err 538 } 539 out := &protocol.AuthDB{} 540 if err := proto.Unmarshal(inflated.Bytes(), out); err != nil { 541 return nil, err 542 } 543 return out, nil 544 }