| // 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:cocoon_service/cocoon_service.dart'; |
| import 'package:dbcrypt/dbcrypt.dart'; |
| import 'package:gcloud/db.dart'; |
| import 'package:meta/meta.dart'; |
| |
| import '../foundation/providers.dart'; |
| import '../foundation/typedefs.dart'; |
| import '../model/appengine/agent.dart'; |
| import '../model/appengine/whitelisted_account.dart'; |
| import '../model/google/token_info.dart'; |
| |
| import 'exceptions.dart'; |
| |
| /// Class capable of authenticating [HttpRequest]s. |
| /// |
| /// There are three types of authentication this class supports: |
| /// |
| /// 1. If the request has the `'Agent-ID'` HTTP header set to the ID of the |
| /// Cocoon agent making the request and the `'Agent-Auth-Token'` HTTP |
| /// header set to the hashed password of the agent, then the request will |
| /// be authenticated as a request being made on behalf of an agent, and the |
| /// [RequestContext.agent] field will be set. |
| /// |
| /// The password should be hashed using the bcrypt algorithm. See |
| /// <https://en.wikipedia.org/wiki/Bcrypt> or |
| /// <https://www.usenix.org/legacy/event/usenix99/provos/provos.pdf> for |
| /// more details. |
| /// |
| /// 2. 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 |
| /// [RequestContext.agent] field will be null (unless the request _also_ |
| /// contained the aforementioned headers). |
| /// |
| /// 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. |
| /// |
| /// 3. If the request has the `'X-Flutter-IdToken'` HTTP cookie or HTTP header |
| /// set to a valid encrypted JWT token, then the request will be authenticated |
| /// as a user account. The [RequestContext.agent] field will be null |
| /// (unless the request _also_ contained the aforementioned headers). |
| /// |
| /// User accounts are only authorized if the user is either a "@google.com" |
| /// account or is a whitelisted account in the Cocoon backend. |
| /// |
| /// 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( |
| this._config, { |
| ClientContextProvider clientContextProvider = Providers.serviceScopeContext, |
| HttpClientProvider httpClientProvider = Providers.freshHttpClient, |
| LoggingProvider loggingProvider = Providers.serviceScopeLogger, |
| }) : assert(_config != null), |
| assert(clientContextProvider != null), |
| assert(httpClientProvider != null), |
| assert(loggingProvider != null), |
| _clientContextProvider = clientContextProvider, |
| _httpClientProvider = httpClientProvider, |
| _loggingProvider = loggingProvider; |
| |
| /// 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; |
| |
| /// Provides the logger. |
| /// |
| /// This is guaranteed to be non-null. |
| final LoggingProvider _loggingProvider; |
| |
| /// 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 String agentId = request.headers.value('Agent-ID'); |
| final bool isCron = request.headers.value('X-Appengine-Cron') == 'true'; |
| final String idTokenFromCookie = request.cookies |
| .where((Cookie cookie) => cookie.name == 'X-Flutter-IdToken') |
| .map<String>((Cookie cookie) => cookie.value) |
| .followedBy(<String>[null]).first; |
| final String idTokenFromHeader = request.headers.value('X-Flutter-IdToken'); |
| final ClientContext clientContext = _clientContextProvider(); |
| final Logging log = _loggingProvider(); |
| |
| if (agentId != null) { |
| // Authenticate as an agent. Note that it could simultaneously be cron |
| // and agent, or Google account and agent. |
| final Key agentKey = _config.db.emptyKey.append(Agent, id: agentId); |
| final Agent agent = |
| await _config.db.lookupValue<Agent>(agentKey, orElse: () { |
| throw Unauthenticated('Invalid agent: $agentId'); |
| }); |
| |
| if (!clientContext.isDevelopmentEnvironment) { |
| final String agentAuthToken = request.headers.value('Agent-Auth-Token'); |
| if (agentAuthToken == null) { |
| throw const Unauthenticated( |
| 'Missing required HTTP header: Agent-Auth-Token'); |
| } |
| if (!_compareHashAndPassword(agent.authToken, agentAuthToken)) { |
| throw Unauthenticated('Invalid agent: $agentId'); |
| } |
| } |
| |
| return AuthenticatedContext._(agent: agent, clientContext: clientContext); |
| } else if (isCron) { |
| // Authenticate cron requests that are not agents. |
| return AuthenticatedContext._(clientContext: clientContext); |
| } else if (idTokenFromCookie != null || idTokenFromHeader != null) { |
| /// There are two possible sources for an id token: |
| /// |
| /// 1. Angular Dart app sends it as a Cookie |
| /// 2. Flutter app sends it as an HTTP header |
| /// |
| /// As long as one of these two id tokens are authenticated, the |
| /// request is authenticated. |
| if (idTokenFromCookie != null) { |
| /// The case where [idTokenFromCookie] is not valid but [idTokenFromHeader] |
| /// is requires us to catch the thrown [Unauthenticated] exception. |
| try { |
| return await authenticateIdToken(idTokenFromCookie, |
| clientContext: clientContext, log: log); |
| } on Unauthenticated { |
| log.debug('Failed to authenticate cookie id token'); |
| } |
| } |
| |
| if (idTokenFromHeader != null) { |
| return authenticateIdToken(idTokenFromHeader, |
| clientContext: clientContext, log: log); |
| } |
| } |
| |
| throw const Unauthenticated('User is not signed in'); |
| } |
| |
| @visibleForTesting |
| Future<AuthenticatedContext> authenticateIdToken(String idToken, |
| {ClientContext clientContext, Logging log}) async { |
| // Authenticate as a signed-in Google account via OAuth id token. |
| final HttpClient client = _httpClientProvider(); |
| try { |
| final HttpClientRequest verifyTokenRequest = |
| await client.getUrl(Uri.https( |
| 'oauth2.googleapis.com', |
| '/tokeninfo', |
| <String, String>{ |
| 'id_token': idToken, |
| }, |
| )); |
| final HttpClientResponse verifyTokenResponse = |
| await verifyTokenRequest.close(); |
| |
| if (verifyTokenResponse.statusCode != HttpStatus.ok) { |
| /// Google Auth API returns a message in the response body explaining why |
| /// the request failed. Such as "Invalid Token". |
| final String body = await utf8.decodeStream(verifyTokenResponse); |
| log.warning( |
| 'Token verification failed: ${verifyTokenResponse.statusCode}; $body'); |
| throw const Unauthenticated('Invalid ID token'); |
| } |
| |
| final String tokenJson = await utf8.decodeStream(verifyTokenResponse); |
| TokenInfo token; |
| try { |
| token = |
| TokenInfo.fromJson(json.decode(tokenJson) as Map<String, dynamic>); |
| } on FormatException { |
| throw InternalServerError('Invalid JSON: "$tokenJson"'); |
| } |
| |
| final String clientId = await _config.oauthClientId; |
| assert(clientId != null); |
| if (token.audience != clientId) { |
| log.warning( |
| 'Possible forged token: "${token.audience}" (expected "$clientId")'); |
| throw const Unauthenticated('Invalid ID token'); |
| } |
| |
| if (token.hostedDomain != 'google.com') { |
| final bool isWhitelisted = await _isWhitelisted(token.email); |
| if (!isWhitelisted) { |
| throw Unauthenticated( |
| '${token.email} is not authorized to access the dashboard'); |
| } |
| } |
| |
| return AuthenticatedContext._(clientContext: clientContext); |
| } finally { |
| client.close(); |
| } |
| } |
| |
| Future<bool> _isWhitelisted(String email) async { |
| final Query<WhitelistedAccount> query = |
| _config.db.query<WhitelistedAccount>() |
| ..filter('email =', email) |
| ..limit(20); |
| |
| return !(await query.run().isEmpty); |
| } |
| |
| // This method is expensive (run time of ~1,500ms!). If the server starts |
| // handling any meaningful API traffic, we should move request processing |
| // to dedicated isolates in a pool. |
| static bool _compareHashAndPassword( |
| List<int> serverAuthTokenHash, String clientAuthToken) { |
| final String serverAuthTokenHashAscii = ascii.decode(serverAuthTokenHash); |
| final DBCrypt crypt = DBCrypt(); |
| try { |
| return crypt.checkpw(clientAuthToken, serverAuthTokenHashAscii); |
| } on String catch (error) { |
| // The bcrypt password hash in the cloud datastore is invalid. |
| throw InternalServerError(error); |
| } |
| } |
| } |
| |
| /// 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._({ |
| this.agent, |
| @required this.clientContext, |
| }) : assert(clientContext != null); |
| |
| /// The agent making the request. |
| /// |
| /// This will be null if the request is not being made by an agent. Even if |
| /// this property is null, the request has been authenticated (by virtue of |
| /// the request context having been created). |
| final Agent agent; |
| |
| /// The App Engine [ClientContext] of the current request. |
| /// |
| /// This is guaranteed to be non-null. |
| final ClientContext clientContext; |
| } |