Skip to content

Commit

Permalink
Support requiring 2FA for authentication, checked with isEnrolledIn2Sv
Browse files Browse the repository at this point in the history
Using Google's Admin SDK Directory API and retrieving the User entity when,
we can check the `isEnrolledIn2Sv` field on https://developers.google.com/admin-sdk/directory/reference/rest/v1/users
to accurately obtain the 2FA status of a user.

At the Guardian, in the past, we've checked the user for membership of the
`2fa_enforce@guardian.co.uk` Google Group, as a proxy for being able to
directly check the 2FA status of the user. The Google Group was manually
administered, so suffered from reconciliation issues, and no longer
corresponds to our onboarding process.

Now, if you supply a `TwoFactorAuthChecker` to the `GoogleAuthConfig`,
the 2FA status of the user will be checked at the point of authentication,
and rejected if `isEnrolledIn2Sv` is false - only users with 2FA will be
allowed to authenticate.
  • Loading branch information
rtyley committed Nov 24, 2023
1 parent 13ed92c commit eb191bd
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 2 deletions.
1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}}
}
11 changes: 11 additions & 0 deletions play-v27/src/main/scala/com/gu/googleauth/actions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion play-v27/src/main/scala/com/gu/googleauth/auth.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion play-v27/src/main/scala/com/gu/googleauth/groups.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}

0 comments on commit eb191bd

Please sign in to comment.