| // Copyright 2019 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:appengine/appengine.dart'; |
| import 'package:gcloud/db.dart'; |
| import 'package:http/http.dart' as http; |
| import 'package:meta/meta.dart'; |
| |
| import '../../cocoon_service.dart'; |
| import '../foundation/providers.dart'; |
| import '../foundation/typedefs.dart'; |
| import '../model/appengine/allowed_account.dart'; |
| import '../model/google/token_info.dart'; |
| import '../service/logging.dart'; |
| import 'exceptions.dart'; |
| |
| /// Class capable of authenticating [HttpRequest]s. |
| /// |
| /// There are two types of authentication this class supports: |
| /// |
| /// 1. If the request has the `'X-Appengine-Cron'` HTTP header set to "true", |
| /// then the request will be authenticated as an App Engine cron job. |
| /// |
| /// The `'X-Appengine-Cron'` HTTP header is set automatically by App Engine |
| /// and will be automatically stripped from the request by the App Engine |
| /// runtime if the request originated from anything other than a cron job. |
| /// Thus, the header is safe to trust as an authentication indicator. |
| /// |
| /// 2. If the request has the `'X-Flutter-IdToken'` HTTP header |
| /// set to a valid encrypted JWT token, then the request will be authenticated |
| /// as a user account. |
| /// |
| /// @google.com accounts can call APIs using curl and gcloud. |
| /// E.g. curl '<api_url>' -H "X-Flutter-IdToken: $(gcloud auth print-identity-token)" |
| /// |
| /// User accounts are only authorized if the user is either a "@google.com" |
| /// account or is an [AllowedAccount] in Cocoon's Datastore. |
| /// |
| /// If none of the above authentication methods yield an authenticated |
| /// request, then the request is unauthenticated, and any call to |
| /// [authenticate] will throw an [Unauthenticated] exception. |
| /// |
| /// See also: |
| /// |
| /// * <https://cloud.google.com/appengine/docs/standard/python/reference/request-response-headers> |
| @immutable |
| class AuthenticationProvider { |
| const AuthenticationProvider({ |
| required this.config, |
| this.clientContextProvider = Providers.serviceScopeContext, |
| this.httpClientProvider = Providers.freshHttpClient, |
| }); |
| |
| /// The Cocoon config, guaranteed to be non-null. |
| final Config config; |
| |
| /// Provides the App Engine client context as part of the |
| /// [AuthenticatedContext]. |
| /// |
| /// This is guaranteed to be non-null. |
| final ClientContextProvider clientContextProvider; |
| |
| /// Provides the HTTP client that will be used (if necessary) to verify OAuth |
| /// ID tokens (JWT tokens). |
| /// |
| /// This is guaranteed to be non-null. |
| final HttpClientProvider httpClientProvider; |
| |
| /// Authenticates the specified [request] and returns the associated |
| /// [AuthenticatedContext]. |
| /// |
| /// See the class documentation on [AuthenticationProvider] for a discussion |
| /// of the different types of authentication that are accepted. |
| /// |
| /// This will throw an [Unauthenticated] exception if the request is |
| /// unauthenticated. |
| Future<AuthenticatedContext> authenticate(HttpRequest request) async { |
| final bool isCron = request.headers.value('X-Appengine-Cron') == 'true'; |
| final String? idTokenFromHeader = request.headers.value('X-Flutter-IdToken'); |
| final ClientContext clientContext = clientContextProvider(); |
| if (isCron) { |
| // Authenticate cron requests |
| return AuthenticatedContext(clientContext: clientContext); |
| } else if (idTokenFromHeader != null) { |
| TokenInfo token; |
| try { |
| token = await tokenInfo(request); |
| } on Unauthenticated { |
| token = await tokenInfo(request, tokenType: 'access_token'); |
| } |
| return authenticateToken(token, clientContext: clientContext); |
| } |
| |
| throw const Unauthenticated('User is not signed in'); |
| } |
| |
| /// Gets oauth token information. This method requires the token to be stored in |
| /// X-Flutter-IdToken header. |
| Future<TokenInfo> tokenInfo(HttpRequest request, {String tokenType = 'id_token'}) async { |
| final String? idTokenFromHeader = request.headers.value('X-Flutter-IdToken'); |
| final http.Client client = httpClientProvider(); |
| try { |
| final http.Response verifyTokenResponse = await client.get( |
| Uri.https( |
| 'oauth2.googleapis.com', |
| '/tokeninfo', |
| <String, String?>{ |
| tokenType: idTokenFromHeader, |
| }, |
| ), |
| ); |
| |
| if (verifyTokenResponse.statusCode != HttpStatus.ok) { |
| /// Google Auth API returns a message in the response body explaining why |
| /// the request failed. Such as "Invalid Token". |
| log.fine('Token verification failed: ${verifyTokenResponse.statusCode}; ${verifyTokenResponse.body}'); |
| throw const Unauthenticated('Invalid ID token'); |
| } |
| |
| try { |
| return TokenInfo.fromJson(json.decode(verifyTokenResponse.body) as Map<String, dynamic>); |
| } on FormatException { |
| throw InternalServerError('Invalid JSON: "${verifyTokenResponse.body}"'); |
| } |
| } finally { |
| client.close(); |
| } |
| } |
| |
| Future<AuthenticatedContext> authenticateToken(TokenInfo token, {required ClientContext clientContext}) async { |
| // Authenticate as a signed-in Google account via OAuth id token. |
| final String clientId = await config.oauthClientId; |
| if (token.audience != clientId && !token.email!.endsWith('@google.com')) { |
| log.warning('Possible forged token: "${token.audience}" (expected "$clientId")'); |
| throw const Unauthenticated('Invalid ID token'); |
| } |
| |
| if (token.hostedDomain != 'google.com') { |
| final bool isAllowed = await _isAllowed(token.email); |
| if (!isAllowed) { |
| throw Unauthenticated('${token.email} is not authorized to access the dashboard'); |
| } |
| } |
| return AuthenticatedContext(clientContext: clientContext); |
| } |
| |
| Future<bool> _isAllowed(String? email) async { |
| final Query<AllowedAccount> query = config.db.query<AllowedAccount>() |
| ..filter('email =', email) |
| ..limit(20); |
| |
| return !(await query.run().isEmpty); |
| } |
| } |
| |
| /// Class that represents an authenticated request having been made, and any |
| /// attached metadata to that request. |
| /// |
| /// See also: |
| /// |
| /// * [AuthenticationProvider] |
| @immutable |
| class AuthenticatedContext { |
| /// Creates a new [AuthenticatedContext]. |
| const AuthenticatedContext({ |
| required this.clientContext, |
| }); |
| |
| /// The App Engine [ClientContext] of the current request. |
| /// |
| /// This is guaranteed to be non-null. |
| final ClientContext clientContext; |
| } |