Skip to content

Commit

Permalink
Merge pull request #204 from guardian/use-isEnrolledIn2Sv-to-check-2FA
Browse files Browse the repository at this point in the history
Support requiring 2FA for authentication, check `isEnrolledIn2Sv`
  • Loading branch information
rtyley committed Nov 30, 2023
2 parents 4e6dde3 + e6cef99 commit 08c10f2
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 = ??? // eg "firstname.lastname@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 08c10f2

Please sign in to comment.