Skip to content

Instantly share code, notes, and snippets.

@sashagavrilov
Last active May 6, 2016 08:42
Show Gist options
  • Save sashagavrilov/142bb4eac40221cdb857 to your computer and use it in GitHub Desktop.
Save sashagavrilov/142bb4eac40221cdb857 to your computer and use it in GitHub Desktop.
Extended state to SocialProviders
trait ExtendedOAuth2State extends OAuth2State {
/**
* Returns a uri to send user after authentication
*
* @return A uri to send user after authentication
*/
def returnTo: Option[String]
}
/**
* A state which gets persisted in a cookie.
*
* This is to prevent the client for CSRF attacks as described in the OAuth2 RFC.
* @see https://tools.ietf.org/html/rfc6749#section-10.12
*
* @param value A value that binds the request to the user-agent's authenticated state.
* @param expirationTime The expiration time.
* @param returnTo A uri to send user after authentication
*/
case class ExtendedCookieState(value: String, expirationTime: DateTime, returnTo: Option[String]) extends ExtendedOAuth2State {
/**
* Checks if the state is expired. This is an absolute timeout since the creation of
* the state.
*
* @return True if the state is expired, false otherwise.
*/
override def isExpired = expirationTime.isBeforeNow
/**
* Returns a serialized value of the state.
*
* @return A serialized value of the state.
*/
override def serialize = ExtendedCookieState.serialize(this)
}
object ExtendedCookieState {
import ExtendedCookieStateProvider._
private val ReturnTo = "returnTo"
/**
* Returns a serialized value of the state.
*
* @param state The state to serialize.
* @return A serialized value of the state.
*/
def serialize(state: ExtendedCookieState) = java.net.URLEncoder.encode(
state.value + queryString(List(
Some(implicitly[QueryStringBindable[Long]].unbind(Expires, state.expirationTime.getMillis)),
state.returnTo.map(returnTo => implicitly[QueryStringBindable[String]].unbind(ReturnTo, returnTo))
)), "utf-8")
/**
* Deserialize the state.
*
* @param str The string representation of the state.
* @return Some state on success, otherwise None.
*/
def deserialize(str: String): Try[ExtendedCookieState] = Try {
java.net.URLDecoder.decode(str, "utf-8").split('?') match {
case Array(value, queryString) if value.nonEmpty =>
val params = RouteParams(path = Map(), queryString = FormUrlEncodedParser.parse(queryString, "utf-8"))
(for {
expires <- params.fromQuery[Long](Expires).value.right
returnTo <- params.fromQuery[Option[String]](ReturnTo).value.right
state <- Right(ExtendedCookieState(value, new DateTime(expires), returnTo)).right
} yield state).fold(
error => throw new OAuth2StateException(InvalidStateFormat),
identity
)
case _ => throw new OAuth2StateException(InvalidStateFormat)
}
}
}
trait ExtendedOAuth2StateProvider extends OAuth2StateProvider {
override type State <: ExtendedOAuth2State
}
/**
*
*/
class ExtendedCookieStateProvider @Inject()(
settings: ExtendedCookieStateSettings,
idGenerator: IDGenerator,
crypto: Crypto,
clock: Clock) extends ExtendedOAuth2StateProvider {
override type State = ExtendedCookieState
object stateCrypto {
def sign(state: String) = {
val nonce = System.currentTimeMillis()
val joined = nonce + "-" + state
crypto.sign(joined) + "-" + nonce
}
def validate(signed: String, raw: String) = {
signed.split("-", 2) match {
case Array(signature, nonce) => signature == crypto.sign(nonce + "-" + raw)
case _ => false
}
}
}
/**
* Builds the state.
*
* @param request The request.
* @param ec The execution context to handle the asynchronous operations.
* @tparam B The type of the request body.
* @return The build state.
*/
override def build[B](implicit request: ExtractableRequest[B], ec: ExecutionContext): Future[ExtendedCookieState] = {
val returnTo = request.extractString(settings.returnToParameter)
idGenerator.generate.map { id =>
ExtendedCookieState(id, clock.now.plusSeconds(settings.cookieState.expirationTime.toSeconds.toInt), returnTo)
}
}
/**
* Sends a cookie to the client containing the serialized state.
*
* @param result The result to send to the client.
* @param state The state to publish.
* @param request The request.
* @tparam B The type of the request body.
* @return The result to send to the client.
*/
override def publish[B](result: Result, state: State)(implicit request: ExtractableRequest[B]): Result = {
result.withCookies(Cookie(name = settings.cookieState.cookieName,
value = stateCrypto.sign(state.serialize),
maxAge = Some(settings.cookieState.expirationTime.toSeconds.toInt),
path = settings.cookieState.cookiePath,
domain = settings.cookieState.cookieDomain,
secure = settings.cookieState.secureCookie,
httpOnly = settings.cookieState.httpOnlyCookie))
}
/**
* Validates the provider and the client state.
*
* @param request The request.
* @param ec The execution context to handle the asynchronous operations.
* @tparam B The type of the request body.
* @return The state on success, otherwise an failure.
*/
override def validate[B](implicit request: ExtractableRequest[B], ec: ExecutionContext): Future[ExtendedCookieState] = Future.from {
for {
(serializedState, providerState) <- providerState
clientState <- clientState
result <- {
if (!stateCrypto.validate(clientState, serializedState)) Failure(new OAuth2StateException(StateIsNotEqual))
else if (providerState.isExpired) Failure(new OAuth2StateException(StateIsExpired))
else Success(providerState)
}
} yield result
}
/**
* Gets the state from request the after the provider has redirected back from the authorization auth.server
* with the access code.
*
* @param request The request.
* @tparam B The type of the request body.
* @return The OAuth2 state on success, otherwise a failure.
*/
private def providerState[B](implicit request: ExtractableRequest[B]): Try[(String, ExtendedCookieState)] = {
request.extractString(State) match {
case Some(state) => ExtendedCookieState.deserialize(state).map(state -> _)
case _ => Failure(new OAuth2StateException(ProviderStateDoesNotExists.format(State)))
}
}
/**
* Gets the state from cookie.
*
* @param request The request header.
* @return The OAuth2 state on success, otherwise a failure.
*/
private def clientState(implicit request: RequestHeader): Try[String] = {
request.cookies.get(settings.cookieState.cookieName) match {
case Some(cookie) => Success(cookie.value)
case None => Failure(new OAuth2StateException(ClientStateDoesNotExists.format(settings.cookieState.cookieName)))
}
}
}
object ExtendedCookieStateProvider {
val InvalidStateFormat = "[Silhouette][ExtendedCookieState] Cannot build OAuth2State because of invalid parameter format."
}
/**
* The settings for the cookie state.
*
*/
case class ExtendedCookieStateSettings(
cookieState: CookieStateSettings,
returnToParameter: String = "returnTo"
)
class UserAuthenticationController @Inject()(
val env: Environment[Identity, CookieAuthenticator],
val socialProviderRegistry: SocialProviderRegistry,
val stateProvider: ExtendedOAuth2StateProvider,
val messagesApi: MessagesApi
)(implicit ec: ExecutionContext) extends Silhouette[Identity, CookieAuthenticator] with Logger {
private val defaultRedirect = "/"
def signIn(returnTo: Option[String]) = Action.async { implicit request =>
(socialProviderRegistry.get[GoogleProvider] match {
case Some(p: GoogleProvider) =>
p.authenticate().flatMap {
case Left(result) => Future.successful(result)
case Right(authInfo) => for {
state <- stateProvider.validate
profile <- p.retrieveProfile(authInfo)
authenticator <- env.authenticatorService.create(profile.loginInfo)
value <- env.authenticatorService.init(authenticator)
redirect = Redirect(state.returnTo.getOrElse(defaultRedirect))
result <- env.authenticatorService.embed(value, redirect)
} yield {
result
}
}
case _ => Future.failed(new ProviderException(s"Cannot authenticate with Google Provider"))
}).recoverWith {
case e: ProviderException =>
logger.error("Unexpected provider error", e)
DefaultEndpointHandler.handleNotAuthenticated(request, request2Messages)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment