diff --git a/build.sbt b/build.sbt index 4087a69..87c3778 100644 --- a/build.sbt +++ b/build.sbt @@ -41,6 +41,7 @@ def projectWithPlayVersion(playVersion: PlayVersion) = "org.typelevel" %% "cats-core" % "2.9.0", commonsCodec, "org.scalatest" %% "scalatest" % "3.2.16" % Test, + "software.amazon.awssdk" % "ssm" % "2.21.7" % Test ) ++ googleDirectoryAPI ++ playVersion.playLibs, sonatypeReleaseSettings diff --git a/play-v27/src/main/scala/com/gu/googleauth/TwoFactorAuthChecker.scala b/play-v27/src/main/scala/com/gu/googleauth/TwoFactorAuthChecker.scala new file mode 100644 index 0000000..1f17a9a --- /dev/null +++ b/play-v27/src/main/scala/com/gu/googleauth/TwoFactorAuthChecker.scala @@ -0,0 +1,22 @@ +package com.gu.googleauth + +import com.google.api.services.directory.DirectoryScopes.ADMIN_DIRECTORY_USER_READONLY +import com.google.auth.oauth2.GoogleCredentials +import com.gu.googleauth.internal.DirectoryService + +import scala.concurrent.{ExecutionContext, Future, blocking} + +/** + * Uses the `isEnrolledIn2Sv` field on https://developers.google.com/admin-sdk/directory/reference/rest/v1/users + * to check the 2FA status of a user. + * + * @param googleCredentials must have read-only access to retrieve a User using the Admin SDK Directory API + */ +class TwoFactorAuthChecker(googleCredentials: GoogleCredentials) { + + private val usersApi = DirectoryService(googleCredentials, ADMIN_DIRECTORY_USER_READONLY).users() + + def check(userEmail: String)(implicit ec: ExecutionContext): Future[Boolean] = Future { blocking { + usersApi.get(userEmail).execute().getIsEnrolledIn2Sv + }} +} diff --git a/play-v27/src/main/scala/com/gu/googleauth/actions.scala b/play-v27/src/main/scala/com/gu/googleauth/actions.scala index bd733e7..77f309b 100644 --- a/play-v27/src/main/scala/com/gu/googleauth/actions.scala +++ b/play-v27/src/main/scala/com/gu/googleauth/actions.scala @@ -137,9 +137,20 @@ trait LoginSupport extends Logging { } logWarn(desc, e) Future.successful(redirectWithError(failureRedirectTarget, message)) + }.flatMap { userIdentity => + authConfig.twoFactorAuthChecker.map(requireTwoFactorAuthFor(userIdentity)).getOrElse(EitherT.pure(userIdentity)) } } + private def requireTwoFactorAuthFor(userIdentity: UserIdentity)(checker: TwoFactorAuthChecker)( + implicit ec: ExecutionContext + ): EitherT[Future, Result, UserIdentity] = EitherT { + checker.check(userIdentity.email).map(userHas2FA => if (userHas2FA) Right(userIdentity) else { + logger.warn(s"failed-oauth-callback : user-does-not-have-2fa") + Left(redirectWithError(failureRedirectTarget, "You do not have 2FA enabled")) + }) + } + /** * Looks up user's Google Groups and ensures they belong to any that are required. Redirects to * `failureRedirectTarget` if the user is not a member of any required group. diff --git a/play-v27/src/main/scala/com/gu/googleauth/auth.scala b/play-v27/src/main/scala/com/gu/googleauth/auth.scala index 1ba5bd2..8ec791a 100644 --- a/play-v27/src/main/scala/com/gu/googleauth/auth.scala +++ b/play-v27/src/main/scala/com/gu/googleauth/auth.scala @@ -34,6 +34,7 @@ import play.api.libs.ws.WSBodyWritables._ * @param prompt An optional space delimited, case sensitive list of ASCII string values that specifies whether the * Authorization Server prompts the End-User for reauthentication and consent * @param antiForgeryChecker configuration for the checks that ensure the OAuth callback can't be forged + * @param twoFactorAuthChecker only allow users to authenticate if they have 2FA enabled */ case class GoogleAuthConfig( clientId: String, @@ -43,7 +44,8 @@ case class GoogleAuthConfig( maxAuthAge: Option[Duration] = GoogleAuthConfig.defaultMaxAuthAge, enforceValidity: Boolean = GoogleAuthConfig.defaultEnforceValidity, prompt: Option[String] = GoogleAuthConfig.defaultPrompt, - antiForgeryChecker: AntiForgeryChecker + antiForgeryChecker: AntiForgeryChecker, + twoFactorAuthChecker: Option[TwoFactorAuthChecker] = None ) object GoogleAuthConfig { diff --git a/play-v27/src/main/scala/com/gu/googleauth/groups.scala b/play-v27/src/main/scala/com/gu/googleauth/groups.scala index 307de0a..1db0a1f 100644 --- a/play-v27/src/main/scala/com/gu/googleauth/groups.scala +++ b/play-v27/src/main/scala/com/gu/googleauth/groups.scala @@ -13,7 +13,9 @@ import scala.jdk.CollectionConverters._ * * You can use a Service Account to access the Directory API (in fact, non-Service access, ie web-user, * doesn't seem to work?). The Service Account needs the following scope: - * https://www.googleapis.com/auth/admin.directory.group.readonly + * https://www.googleapis.com/auth/admin.directory.group.readonly - note that if you're using + * [[TwoFactorAuthChecker]] it requires a different scope: + * https://www.googleapis.com/auth/admin.directory.user.readonly * * So long as you have the Service Account certificate as a string, you can easily make * an instance of com.google.auth.oauth2.ServiceAccountCredentials with diff --git a/play-v27/src/test/scala/com/gu/googleauth/TwoFactorAuthCheckerCLITester.scala b/play-v27/src/test/scala/com/gu/googleauth/TwoFactorAuthCheckerCLITester.scala new file mode 100644 index 0000000..fad1ba6 --- /dev/null +++ b/play-v27/src/test/scala/com/gu/googleauth/TwoFactorAuthCheckerCLITester.scala @@ -0,0 +1,51 @@ +package com.gu.googleauth + +import com.google.auth.oauth2.GoogleCredentials +import software.amazon.awssdk.auth.credentials.{AwsCredentialsProvider, ProfileCredentialsProvider} +import software.amazon.awssdk.http.apache.ApacheHttpClient +import software.amazon.awssdk.regions.Region.EU_WEST_1 +import software.amazon.awssdk.services.ssm.SsmClient +import software.amazon.awssdk.services.ssm.model.GetParameterRequest + +import scala.concurrent.Await +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.duration.Duration + +/** + * This is a non-production piece of code, only intended for checking that Google Credentials are correctly set up, + * and that we're able to get a good response from the Google Admin SDK Directory API. + */ +object TwoFactorAuthCheckerCLITester extends App { + + val checker = new TwoFactorAuthChecker(GoogleCredentialsInDev.loadGuardianGoogleCredentials()) + + val userEmail = "roberto.tyley@guardian.co.uk" + + val has2FA: Boolean = Await.result(checker.check(userEmail), Duration.Inf) + + println(s"User $userEmail has 2FA enabled: $has2FA") +} + +object GoogleCredentialsInDev { + /** + * Only intended for use by Guardian developers, in development. Requires Janus credentials. + */ + def loadGuardianGoogleCredentials(): GoogleCredentials = { + val aws = new AWS("ophan") + val serviceAccountCert = aws.loadSecureString("/Ophan/Dashboard/GoogleCloudPlatform/OphanOAuthServiceAccountCert") + val impersonatedUser = aws.loadSecureString("/Ophan/Dashboard/GoogleCloudPlatform/ImpersonatedUser") + + ServiceAccountHelper.credentialsFrom(serviceAccountCert).createDelegated(impersonatedUser) + } +} + +class AWS(profileName: String) { + val credentials: AwsCredentialsProvider = ProfileCredentialsProvider.builder().profileName(profileName).build() + + val SSM: SsmClient = + SsmClient.builder().httpClientBuilder(ApacheHttpClient.builder()).credentialsProvider(credentials).region(EU_WEST_1).build() + + def loadSecureString(paramName: String): String = SSM.getParameter( + GetParameterRequest.builder().name(paramName).withDecryption(true).build() + ).parameter().value() +} \ No newline at end of file