From 431ece42b5a8f02c166a62e340823465de1c2a25 Mon Sep 17 00:00:00 2001 From: Aleksi Ahtiainen Date: Wed, 2 Oct 2024 18:29:04 +0300 Subject: [PATCH] Toteuta frontend-polku minimitarkistuksin; MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backendin toteutus vielä puuttuu, mutta client_id:n ja redirect_uri:n oikeellisuudesta pidetään huolta. --- src/main/resources/reference.conf | 27 + src/main/scala/ScalatraBootstrap.scala | 11 +- .../koski/cas/CasAuthenticatingClient.scala | 2 + .../fi/oph/koski/koskiuser/MockUsers.scala | 14 +- ...DataOAuth2AuthorizationServerServlet.scala | 107 +++- .../OmaDataOAuth2CASWorkaroundServlet.scala | 32 ++ .../omadataoauth2/OmaDataOAuth2Config.scala | 27 + ...aDataOAuth2LogoutPostResponseServlet.scala | 58 ++ ...maDataOAuth2PostResponseDebugServlet.scala | 43 ++ ...aDataOAuth2ResourceOwnerReactServlet.scala | 117 ++++ .../OmaDataOAuth2ResourceOwnerServlet.scala | 93 ++++ .../omadataoauth2/OmaDataOAuth2Support.scala | 101 ++++ .../OmaDataOAuth2BackendSpec.scala | 93 ++++ .../OmaDataOAuth2ClientDetailsSpec.scala | 45 ++ .../OmaDataOAuth2FrontendSpec.scala | 501 ++++++++++++++++++ .../omadataoauth2/OmaDataOAuth2Spec.scala | 58 -- .../omadataoauth2/OmaDataOAuth2TestBase.scala | 21 + web/app/omadata/HyvaksyntaLanding.jsx | 1 - .../omadata/OmaDataOAuth2AnnaHyvaksynta.jsx | 114 ++++ .../OmaDataOAuth2HyvaksyntaLanding.jsx | 112 ++++ .../omadata/OmaDataOAuth2UusiHyvaksynta.jsx | 48 ++ web/app/style/main.less | 1 + web/app/style/omadataoauth2.less | 364 +++++++++++++ web/webpack.config.js | 1 + 24 files changed, 1908 insertions(+), 83 deletions(-) create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2CASWorkaroundServlet.scala create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Config.scala create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2LogoutPostResponseServlet.scala create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2PostResponseDebugServlet.scala create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerReactServlet.scala create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerServlet.scala create mode 100644 src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Support.scala create mode 100644 src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2BackendSpec.scala create mode 100644 src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ClientDetailsSpec.scala create mode 100644 src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2FrontendSpec.scala delete mode 100644 src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Spec.scala create mode 100644 src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2TestBase.scala create mode 100644 web/app/omadata/OmaDataOAuth2AnnaHyvaksynta.jsx create mode 100644 web/app/omadata/OmaDataOAuth2HyvaksyntaLanding.jsx create mode 100644 web/app/omadata/OmaDataOAuth2UusiHyvaksynta.jsx create mode 100644 web/app/style/omadataoauth2.less diff --git a/src/main/resources/reference.conf b/src/main/resources/reference.conf index 789f30f0d4..6ad85cbd98 100644 --- a/src/main/resources/reference.conf +++ b/src/main/resources/reference.conf @@ -216,6 +216,33 @@ mydata = { ] } +omadataoauth2 = { + login { + cas { + fi = "/koski/login/oppija" # Login: "Korhopankki", or Tupas in production + sv = ${mydata.login.cas.fi} + targetparam = "?service=" # Parameter which defines where to redirect user after login. + } + servlet = "/koski/user/login" # This is where we land after cas login + } + clients = [ + { + client_id = "omadataoauth2sample" + redirect_uris = [ + "http://localhost:7051/form-post-response-cb" + ] + }, + { + client_id = "oauth2client" + redirect_uris = [ + "http://localhost:7021/koski/omadata-oauth2/debug-post-response" + "/koski/omadata-oauth2/debug-post-response" + ] + } + ] +} + + raportit = { rajatut = [] } diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 6aa657d726..f7ab75fd70 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -20,7 +20,7 @@ import fi.oph.koski.log.Logging import fi.oph.koski.luovutuspalvelu.{PalveluvaylaServlet, TilastokeskusServlet} import fi.oph.koski.migri.MigriServlet import fi.oph.koski.mydata.{ApiProxyServlet, MyDataReactServlet, MyDataServlet} -import fi.oph.koski.omadataoauth2.{OmaDataOAuth2AuthorizationServerServlet, OmaDataOAuth2ResourceServerServlet} +import fi.oph.koski.omadataoauth2.{OmaDataOAuth2AuthorizationServerServlet, OmaDataOAuth2CASWorkaroundServlet, OmaDataOAuth2LogoutPostResponseServlet, OmaDataOAuth2PostResponseDebugServlet, OmaDataOAuth2ResourceOwnerReactServlet, OmaDataOAuth2ResourceOwnerServlet, OmaDataOAuth2ResourceServerServlet} import fi.oph.koski.omaopintopolkuloki.OmaOpintoPolkuLokiServlet import fi.oph.koski.omattiedot.{OmatTiedotHtmlServlet, OmatTiedotServlet, OmatTiedotServletV2} import fi.oph.koski.opiskeluoikeus.{OpiskeluoikeusServlet, OpiskeluoikeusValidationServlet} @@ -180,8 +180,13 @@ class ScalatraBootstrap extends LifeCycle with Logging with Timing with GlobalEx if (!Environment.isProdEnvironment(application.config)) { mount("/koski/api/omadata-oauth2/authorization-server", new OmaDataOAuth2AuthorizationServerServlet) mount("/koski/api/omadata-oauth2/resource-server", new OmaDataOAuth2ResourceServerServlet) - // mount("/koski/api/omadata-oauth2/resource-owner", new OmaDataOAuth2ResourceOwnerServlet) // TODO: Routet valtuutuksen myöntämiselle yms liittyvälle - // mount("/koski/omadata-oauth2", new OmaDataOAuth2ReactServlet) // TODO: Routet valtuutuksen myöntö frontille + mount("/koski/api/omadata-oauth2/resource-owner", new OmaDataOAuth2ResourceOwnerServlet) + mount("/koski/omadata-oauth2/post-response", new OmaDataOAuth2LogoutPostResponseServlet) + mount("/koski/omadata-oauth2", new OmaDataOAuth2ResourceOwnerReactServlet) + mount("/koski/omadata-oauth2/cas-workaround", new OmaDataOAuth2CASWorkaroundServlet) + + // TODO: TOR-2210: Poista debug-servlet kokonaan! + mount("/koski/omadata-oauth2/debug-post-response", new OmaDataOAuth2PostResponseDebugServlet) } if (Environment.isLocalDevelopmentEnvironment(application.config)) { diff --git a/src/main/scala/fi/oph/koski/cas/CasAuthenticatingClient.scala b/src/main/scala/fi/oph/koski/cas/CasAuthenticatingClient.scala index dcbf122431..c7501c3262 100644 --- a/src/main/scala/fi/oph/koski/cas/CasAuthenticatingClient.scala +++ b/src/main/scala/fi/oph/koski/cas/CasAuthenticatingClient.scala @@ -80,3 +80,5 @@ object CasAuthenticatingClient extends Logging { } } } + + diff --git a/src/main/scala/fi/oph/koski/koskiuser/MockUsers.scala b/src/main/scala/fi/oph/koski/koskiuser/MockUsers.scala index b327b5499a..325f7f23d4 100644 --- a/src/main/scala/fi/oph/koski/koskiuser/MockUsers.scala +++ b/src/main/scala/fi/oph/koski/koskiuser/MockUsers.scala @@ -36,6 +36,17 @@ object MockUsers { ))) ) + val rekisteröimätönOmadataOAuth2Palvelukäyttäjä = KoskiMockUser( + "oauth2clienteirek", + "oauth2clienteirek", + "1.2.246.562.24.99999984729", + Seq(OrganisaatioJaKäyttöoikeudet(MockOrganisaatiot.dvv, List( + PalveluJaOikeus("KOSKI", Rooli.OMADATAOAUTH2_OPISKELUOIKEUDET_SUORITETUT_TUTKINNOT), + PalveluJaOikeus("KOSKI", Rooli.OMADATAOAUTH2_HENKILOTIEDOT_NIMI), + PalveluJaOikeus("KOSKI", Rooli.OMADATAOAUTH2_HENKILOTIEDOT_SYNTYMAAIKA), + ))) + ) + val omniaPalvelukäyttäjä = KoskiMockUser( "käyttäjä", "omnia-palvelukäyttäjä", @@ -608,7 +619,8 @@ object MockUsers { xssHyökkääjä, muuKuinSäänneltyKoulutusYritys, pohjoiskalotinKoulutussäätiöKäyttäjä, - omadataOAuth2Palvelukäyttäjä + omadataOAuth2Palvelukäyttäjä, + rekisteröimätönOmadataOAuth2Palvelukäyttäjä ) } diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2AuthorizationServerServlet.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2AuthorizationServerServlet.scala index 8f463ba7e9..1d9d120739 100644 --- a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2AuthorizationServerServlet.scala +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2AuthorizationServerServlet.scala @@ -1,7 +1,6 @@ package fi.oph.koski.omadataoauth2 import fi.oph.koski.config.KoskiApplication -import fi.oph.koski.http.{HttpStatus, KoskiErrorCategory} import fi.oph.koski.koskiuser.RequiresOmaDataOAuth2 import fi.oph.koski.log.Logging import fi.oph.koski.servlet.{KoskiSpecificApiServlet, NoCache} @@ -13,51 +12,119 @@ import org.scalatra.i18n.I18nSupport class OmaDataOAuth2AuthorizationServerServlet(implicit val application: KoskiApplication) extends KoskiSpecificApiServlet - with Logging with ContentEncodingSupport with NoCache with FormSupport with I18nSupport with RequiresOmaDataOAuth2 -{ + with Logging with ContentEncodingSupport with NoCache with FormSupport with I18nSupport with RequiresOmaDataOAuth2 with OmaDataOAuth2Support { // in: auth code yms. OAuth2 headerit // out: access token, jos käyttäjällä oikeudet kyseiseen authorization codeen. - // TAI OAuth2-protokollan mukainen virheilmoitus (joka luotetaan nginx:n välittävän sellaisenaan, jos pyyntö on tänne asti tullut?) - // TODO: Lisävarmistus, että hyväksytään vain "application/x-www-form-urlencoded"-tyylinen input? + // TAI OAuth2-protokollan mukainen virheilmoitus ( + // TODO: TOR-2210 joka luotetaan nginx:n välittävän sellaisenaan, jos pyyntö on tänne asti tullut?) + // TODO: TOR-2210 Lisävarmistus, että hyväksytään vain "application/x-www-form-urlencoded"-tyylinen input? post("/") { - val result = validate(AccessTokenRequest.formAccessTokenRequest)( + val result: AccessTokenResponse = validate(AccessTokenRequest.formAccessTokenRequest)( (errors: Seq[(String, String)]) => { - // TODO: OAuth2-standardin mukaiset virheet - Left(KoskiErrorCategory.badRequest(errors.map { case (a, b) => s"${a}: ${b}" }.mkString(";"))) + val validationError = ValidationError(ValidationErrorType.invalid_request, errors.map { case (a, b) => s"${a}: ${b}" }.mkString(";"), ReportingType.ToRedirectUri) + + logger.warn(validationError.getLoggedErrorMessage) + AccessTokenErrorResponse(validationError) }, (accessTokenRequest: AccessTokenRequest) => { - // TODO: Oikea toteutus ja yksikkötestit - // Bearer-token spec: https://www.rfc-editor.org/rfc/rfc6750 - Right(AccessTokenResponse(access_token = "dummy-access-token", token_type = "Bearer", expires_in = 86400)) + validateAccessTokenRequest(accessTokenRequest) match { + case Left(validationError) => + + logger.warn(validationError.getLoggedErrorMessage) + AccessTokenErrorResponse(validationError) + case _ => + // TODO: Oikea toteutus ja yksikkötestit + // Bearer-token spec: https://www.rfc-editor.org/rfc/rfc6750 + + AccessTokenSuccessResponse( + access_token = "dummy-access-token", + token_type = "Bearer", + expires_in = 86400 + ) + } } ) - renderEither(result) + response.setStatus(result.httpStatus) + renderObject(result) + } + + protected def validateAccessTokenRequest(request: AccessTokenRequest): Either[ValidationError, Unit] = { + for { + _ <- validateClientId(request.client_id) + } yield () + } + + protected def validateClientId(clientIdParam: Option[String]): Either[ValidationError, String] = { + clientIdParam match { + case Some(clientId) => + // clientId annettu parametrina, tarkista, että vastaa mtls:n kautta saatua + for { + clientId <- validateClientIdRekisteröity(clientId) + _ <- validateClientIdSamaKuinKäyttäjätunnus(clientId) + } yield clientId + case _ => + // käytä mtls:n kautta saatua client_id:tä + for { + clientId <- validateClientIdRekisteröity(koskiSession.user.username) + } yield clientId + } } -} + protected def validateClientIdSamaKuinKäyttäjätunnus(clientId: String): Either[ValidationError, String] = { + if (koskiSession.user.username == clientId) { + Right(clientId) + } else { + Left(ValidationError(ValidationErrorType.invalid_client_data, s"Annettu client_id ${clientId} on eri kuin mTLS-käyttäjä ${koskiSession.user.username}", ReportingType.ToRedirectUri)) + } + } +} object AccessTokenRequest { - // TODO: Voiko tehdä omia dynaaamisia constrainteja, jotka tsekkaa muitakin asioita, esim. tietokannasta asti? val formAccessTokenRequest: MappingValueType[AccessTokenRequest] = mapping( "grant_type" -> label("grant_type", text(required, oneOf(Seq("authorization_code")))), - "code" -> label("code", text(required, oneOf(Seq("foobar")))), // TODO: Oikea koodi-patternin validointi? - "redirect_uri" -> label("redirect_uri", optional(text())), // TODO: URI pattern matching? Oikeasti tarve on verrata, että tämä täsmälleen sama, millä on luotu - "client_id" -> label("client_id", optional(text())) // TODO: Tsekkaa, että vastaa käyttäjää, jolla tultiin sisään, jos on annettu? + "code" -> label("code", text(required, oneOf(Seq("foobar")))), // TODO: TOR-2210 Oikea koodi-patternin validointi? + "redirect_uri" -> label("redirect_uri", optional(text())), // TODO: TOR-2210 Vertaa, että tämän sisältö on täsmälleen sama kuin alkuperäisessä pyynnössä, jos siellä on redirect_uri annettu + "client_id" -> label("client_id", optional(text())) )(AccessTokenRequest.apply) } case class AccessTokenRequest( grant_type: String, code: String, - redirect_uri: Option[String], + redirect_uri: Option[String], // Pitää + // olla annettu ja sama, jos on autorisointiöpyynnössä annettu client_id: Option[String] ) -case class AccessTokenResponse( +trait AccessTokenResponse { + def httpStatus: Int +} + +case class AccessTokenSuccessResponse( access_token: String, token_type: String, expires_in: Long, // refresh_token: Option[String] // jos tarvitaan refresh token // scope: Option[String] // jos aletaan joskus tukea scopen vaihtoa tässä vaiheessa -) +) extends AccessTokenResponse { + val httpStatus = 200 +} + +object AccessTokenErrorResponse { + def apply(validationError: ValidationError): AccessTokenErrorResponse = { + AccessTokenErrorResponse( + "invalid_client", + Some(validationError.getLoggedErrorMessage), + None + ) + } +} + +case class AccessTokenErrorResponse( + error: String, + error_description: Option[String], + error_uri: Option[String] +) extends AccessTokenResponse { + val httpStatus = 400 +} diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2CASWorkaroundServlet.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2CASWorkaroundServlet.scala new file mode 100644 index 0000000000..2f1f2a14e7 --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2CASWorkaroundServlet.scala @@ -0,0 +1,32 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.config.{Environment, KoskiApplication} +import fi.oph.koski.frontendvalvonta.FrontendValvontaMode +import fi.oph.koski.koskiuser.KoskiSpecificAuthenticationSupport +import fi.oph.koski.servlet.{OmaOpintopolkuSupport, OppijaHtmlServlet} +import org.scalatra.ScalatraServlet + +// TODO: TOR-2210: Turhia kantaluokkia tässä? + +// Workaround: CAS-oppija ei päästä paluuosoitteessa olevia query-parametreja läpi. Ne on siksi base64url-enkoodattu path-parametriksi. +class OmaDataOAuth2CASWorkaroundServlet(implicit val application: KoskiApplication) extends ScalatraServlet + with OppijaHtmlServlet with KoskiSpecificAuthenticationSupport with OmaOpintopolkuSupport with OmaDataOAuth2Support with OmaDataOAuth2Config { + + val allowFrameAncestors: Boolean = !Environment.isServerEnvironment(application.config) + val frontendValvontaMode: FrontendValvontaMode.FrontendValvontaMode = + FrontendValvontaMode(application.config.getString("frontend-valvonta.mode")) + + get("/authorize/:base64UrlEnkoodattuPaluuosoitteenParametrilista")(nonce => { + val decodedParameters = base64UrlDecode(params("base64UrlEnkoodattuPaluuosoitteenParametrilista")) + val decodedUrl = s"/koski/omadata-oauth2/authorize?${decodedParameters}" + + redirect(decodedUrl) + }) + + get("/post-response/:base64UrlEnkoodattuPaluuosoitteenParametrilista")(nonce => { + val decodedParameters = base64UrlDecode(params("base64UrlEnkoodattuPaluuosoitteenParametrilista")) + val decodedUrl = s"/koski/omadata-oauth2/post-response?${decodedParameters}" + + redirect(decodedUrl) + }) +} diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Config.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Config.scala new file mode 100644 index 0000000000..82ddfd409f --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Config.scala @@ -0,0 +1,27 @@ +package fi.oph.koski.omadataoauth2 + +import com.typesafe.config.{Config => TypeSafeConfig} +import fi.oph.koski.config.KoskiApplication +import fi.oph.koski.log.Logging +import scala.collection.JavaConverters._ + + +trait OmaDataOAuth2Config extends Logging { + def application: KoskiApplication + protected def conf: TypeSafeConfig = application.config.getConfig("omadataoauth2") + + def hasConfigForClient(client_id: String): Boolean = getConfigOption(client_id).isDefined + + def hasRedirectUri(client_id: String, redirect_uri: String): Boolean = { + getConfigOption(client_id) match { + case Some(clientConfig) => + clientConfig.getStringList("redirect_uris").asScala.contains(redirect_uri) + case _ => false + } + } + + private def getConfigOption(client_id: String): Option[TypeSafeConfig] = { + conf.getConfigList("clients").asScala.find(member => member.getString("client_id") == client_id) + } + +} diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2LogoutPostResponseServlet.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2LogoutPostResponseServlet.scala new file mode 100644 index 0000000000..6b0c9ff227 --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2LogoutPostResponseServlet.scala @@ -0,0 +1,58 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.config.{Environment, KoskiApplication} +import fi.oph.koski.frontendvalvonta.FrontendValvontaMode +import fi.oph.koski.servlet.{NoCache, OppijaHtmlServlet} +import org.scalatra.ScalatraServlet +import fi.oph.koski.util.JsStringInterpolation._ + +import scala.xml.NodeSeq + +// Julkinen servlet, joka tarvitaan, että CAS-oppija logoutin jälkeen voidaan ohjata käyttäjä URLeihin, joita CAS-oppijan +// nykyiset redirect-regexpit eivät salli. +class OmaDataOAuth2LogoutPostResponseServlet(implicit val application: KoskiApplication) extends ScalatraServlet with OppijaHtmlServlet with NoCache with OmaDataOAuth2Support { + + val allowFrameAncestors: Boolean = !Environment.isServerEnvironment(application.config) + val frontendValvontaMode: FrontendValvontaMode.FrontendValvontaMode = + FrontendValvontaMode(application.config.getString("frontend-valvonta.mode")) + + get("/")(nonce => { + validateQueryClientParams() match { + case Left(validationError) => + logger.error(s"Internal error: ${validationError.loggedMessage}") + halt(500) + case Right(ClientInfo(clientId, redirectUri)) => + val paramNames = Seq("redirect_uri", "code", "state", "error", "error_description", "error_uri") + paramNames.foreach(n => logger.info(s"${n}: ${multiParams(n)}")) + + val inputParams = Seq( + "state", + "code", + "error", + "error_description", + "error_uri" + ) + + + + + Submit This Form + + + + +
+ {inputParams.map(renderInputIfParameterDefined)} +
+ + + } + }) + + private def renderInputIfParameterDefined(paramName: String) = { + multiParams(paramName).headOption.map(v => ).getOrElse(NodeSeq.Empty) + } +} diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2PostResponseDebugServlet.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2PostResponseDebugServlet.scala new file mode 100644 index 0000000000..ee58acf707 --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2PostResponseDebugServlet.scala @@ -0,0 +1,43 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.config.{KoskiApplication} +import fi.oph.koski.http.KoskiErrorCategory +import fi.oph.koski.koskiuser.Unauthenticated +import fi.oph.koski.log.Logging +import fi.oph.koski.servlet.{ KoskiSpecificApiServlet, NoCache} +import org.scalatra.ContentEncodingSupport +import org.scalatra.forms._ +import org.scalatra.i18n.I18nSupport + +// TODO: TOR-2210: Poista tämä: tehty vain post-responsen alustavaa debuggausta varten. +class OmaDataOAuth2PostResponseDebugServlet(implicit val application: KoskiApplication) + extends KoskiSpecificApiServlet + with Logging with ContentEncodingSupport with NoCache with FormSupport with I18nSupport with Unauthenticated +{ + post("/") { + val result = validate(SubmitRequest.formSubmitRequest)( + (errors: Seq[(String, String)]) => { + Left(KoskiErrorCategory.badRequest(errors.map { case (a, b) => s"${a}: ${b}" }.mkString(";"))) + }, + (submitRequest: SubmitRequest) => { + Right(submitRequest) + } + ) + + renderEither(result) + } +} + +object SubmitRequest { + val formSubmitRequest: MappingValueType[SubmitRequest] = mapping( + "code" -> label("code", optional(text())), + "state" -> label("state", optional(text())), + "error" -> label("error", optional(text())) + )(SubmitRequest.apply) +} + +case class SubmitRequest( + code: Option[String], + state: Option[String], + error: Option[String] +) diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerReactServlet.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerReactServlet.scala new file mode 100644 index 0000000000..83c6df3b95 --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerReactServlet.scala @@ -0,0 +1,117 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.config.{Environment, KoskiApplication} +import fi.oph.koski.frontendvalvonta.FrontendValvontaMode +import fi.oph.koski.koskiuser.KoskiSpecificAuthenticationSupport +import fi.oph.koski.servlet.{OmaOpintopolkuSupport, OppijaHtmlServlet} +import org.scalatra.{MatchedRoute, ScalatraServlet} + +class OmaDataOAuth2ResourceOwnerReactServlet(implicit val application: KoskiApplication) extends ScalatraServlet + with OppijaHtmlServlet with KoskiSpecificAuthenticationSupport with OmaOpintopolkuSupport with OmaDataOAuth2Support with OmaDataOAuth2Config { + + val allowFrameAncestors: Boolean = !Environment.isServerEnvironment(application.config) + val frontendValvontaMode: FrontendValvontaMode.FrontendValvontaMode = + FrontendValvontaMode(application.config.getString("frontend-valvonta.mode")) + + get("/authorize")(nonce => { + setLangCookieFromDomainIfNecessary + val lang = langFromCookie.getOrElse(langFromDomain) + + if (multiParams("error").length > 0) { + // Näytetään virhe riippumatta sisäänkirjautumisstatuksesta + landerHtml(nonce) + } else { + (validateQueryParams(), isAuthenticated) match { + case (Left(validationError), _) if validationError.reportingType == ReportingType.ToResourceOwner => + // Parametreissa havaittiin käyttäjälle rendattavia virheitä => redirectaa samaan routeen virhetietojen kanssa niiden näyttämiseksi + logger.warn(validationError.getLoggedErrorMessage) + + redirect(s"/koski/omadata-oauth2/authorize?${getParamsWithError(validationError)}") + case (Left(validationError), _) => + // Parametreissa havaittiin virheitä, jotka kuuluu raportoida redirect_uri:n kautta clientille asti + // TODO: TOR-2210: toteuta + logger.error(s"Internal error: ${validationError.loggedMessage}") + halt(500) + case (_, true) => + // Käyttäjä kirjautunut sisään, näytä sisältö + landerHtml(nonce) + case _ => + // Redirect CAS-kirjautumisen kautta + val casLoginURL = getCasLoginURL(lang) + redirect(casLoginURL) + } + } + + // ===> TODO: TOR-2210: Virheilmoitus redirect_uri:in: + // If the resource owner denies the access request or if the request + // fails for reasons other than a missing or invalid redirection URI, + // the authorization server informs the client by adding the following + // parameters to the query component of the redirection URI using the + // "application/x-www-form-urlencoded" format, per Appendix B: + }) + + private def getParamsWithError(validationError: ValidationError): String = { + getCurrentURLParams match { + case Some(existingParams) => + existingParams + s"&${validationError.getClientErrorParams}" + case _ => + s"${validationError.getClientErrorParams}" + } + } + + private def getCasLoginURL(lang: String): String = { + val targetUrl = (request.getRequestURI, getCurrentURLParams) match { + case (_, Some(requestParamsNoEncoding)) => + // Käytä base64url-enkoodaus-workaroundia, koska URL sisälsi query-parametreja + val noQueryParamsWorkaroundTarget = s"/koski/omadata-oauth2/cas-workaround/authorize/${base64UrlEncode(requestParamsNoEncoding)}" + noQueryParamsWorkaroundTarget + case (requestURI, _) => + // Suora redirect onnistuu, koska alkuperäisessa URLissa ei ole query-parametreja + requestURI + } + + val loginUrl = getLoginURL(targetUrl) + + conf.getString(s"login.cas.$lang") + + conf.getString("login.cas.targetparam") + loginUrl + + "&valtuudet=false" + + getKorhopankkiRedirectURLParameter(targetUrl) + } + + private def getLoginURL(target: String): String = { + // CAS ei halua, että sille annettavaa URL:ia enkoodataan, siksi tässä ei sitä tehdä. + s"${omaDataOAuth2LoginServletURL}?onSuccess=${target}" + } + + private def omaDataOAuth2LoginServletURL: String = + conf.getString("login.servlet") + + private def getKorhopankkiRedirectURLParameter(target: String): String = { + val security = application.config.getString("login.security") + + if(security == "mock") { + s"&redirect=${urlEncode(target)}" + } else { + "" + } + } + + private def getCurrentURLParams: Option[String] = { + if (request.queryString.isEmpty) { + None + } else { + Some(request.queryString) + } + } + + private def landerHtml(nonce: String) = { + val paramNames = Seq("client_id", "response_type", "response_mode", "redirect_uri", "code_challenge", "code_challenge_method", "state", "scope", "error", "error_id") + paramNames.foreach(n => logger.info(s"${n}: ${multiParams(n)}")) + + htmlIndex( + scriptBundleName = "koski-omadataoauth2.js", + responsive = true, + nonce = nonce + ) + } +} diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerServlet.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerServlet.scala new file mode 100644 index 0000000000..ec638962c0 --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ResourceOwnerServlet.scala @@ -0,0 +1,93 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.config.KoskiApplication +import fi.oph.koski.koskiuser.{KoskiSpecificAuthenticationSupport, RequiresKansalainen} +import fi.oph.koski.log.Logging +import fi.oph.koski.servlet.{KoskiSpecificApiServlet, LanguageSupport, NoCache} +import org.scalatra.ContentEncodingSupport + + +class OmaDataOAuth2ResourceOwnerServlet(implicit val application: KoskiApplication) + extends KoskiSpecificApiServlet with KoskiSpecificAuthenticationSupport with Logging with ContentEncodingSupport with NoCache with LanguageSupport with OmaDataOAuth2Support with RequiresKansalainen { + + get("/client-details/:client_id") { + // TODO: TOR-2210: oikea toteutus + renderObject(ClientDetails(params("client_id"), "Clientin selväkielinen nimi (TODO)")) + } + + get("/authorize") { + // TODO: TOR-2210: + // - parsi ja tarkista parametrit kunnolla, tarkista, ettei riko speksejä (esim. duplikaatteja) + // - tarkista redirect_uri, että on sallittujen listalla + // - luo authorization code, tallenna code_challenge yms. sen yhteyteen + + val paramNames = Seq("client_id", "response_type", "response_mode", "redirect_uri", "code_challenge", "code_challenge_method", "state", "scope", "error", "error_id", "error_description", "error_uri") + paramNames.foreach(n => logger.info(s"${n}: ${multiParams(n)}")) + + if (multiParams("error").length > 0) { + validateQueryClientParams() match { + case Left(validationError) => + // sisäinen virhe, tänne ei pitäisi päätyä, koska client-parametrit olisi pitänyt jo validoida aiemmin + // TODO: TOR-2210: tässä voisi kuitenkin palata fronttiin sisäisen virheilmoituksen kera? koska clientillekaan ei mitään tiedoteta + logger.error(s"Internal error: ${validationError.loggedMessage}") + halt(500) + case Right(ClientInfo(clientId, redirectUri)) => + + val parameters = + Seq( + ("client_id", clientId), + ("redirect_uri", redirectUri) + ) ++ + multiParams("state").headOption.toSeq.map(v => ("state", v)) ++ + Seq(("error", params("error"))) ++ + multiParams("error_description").headOption.toSeq.map(v => ("error_description", v)) ++ + multiParams("error_uri").headOption.toSeq.map(v => ("error_uri", v)) + + val postResponseParams = createParamsString(parameters) + + redirectToPostResponseViaLogout(postResponseParams) + } + } else { + validateQueryParams() match { + case Left(validationError) if validationError.reportingType == ReportingType.ToResourceOwner => + // Parametreissa havaittiin käyttäjälle rendattavia virheitä => redirectaa takaisin fronttiin virhetietojen kera + // TODO: TOR-2210 + logger.error(s"Internal error: ${validationError.loggedMessage}") + halt(500) + case Left(validationError) => + // Parametreissa havaittiin virheitä, jotka kuuluu raportoida redirect_uri:n kautta clientille asti, redirectaa virhetietojen kanssa samaan osoitteeseen + // TODO: TOR-2210 + logger.error(s"Internal error: ${validationError.loggedMessage}") + halt(500) + case Right(ClientInfo(clientId, redirectUri)) => + // Parametrit ok, välitä post-responsen ja logoutin kautta tiedot clientille + + val parameters = Seq( + ("client_id", clientId), + ("redirect_uri", redirectUri), + ) ++ + multiParams("state").headOption.toSeq.map(v => ("state", v)) ++ + Seq( + ("code", "foobar") + ) + + val postResponseParams = createParamsString(parameters) + + redirectToPostResponseViaLogout(postResponseParams) + } + } + } + + private def redirectToPostResponseViaLogout(postResponseParams: String) = { + val postResponseParamsBase64UrlEncoded = base64UrlEncode(postResponseParams) + val logoutRedirect = s"/koski/omadata-oauth2/cas-workaround/post-response/${postResponseParamsBase64UrlEncoded}" + val logoutUri = s"/koski/user/logout?target=${logoutRedirect}" + + redirect(logoutUri) + } +} + +case class ClientDetails( + id: String, + name: String // TODO: TOR-2210 lokalisoitu merkkijono, josta frontti päättää minkä kielen rendaa? +) diff --git a/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Support.scala b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Support.scala new file mode 100644 index 0000000000..4cd548b104 --- /dev/null +++ b/src/main/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Support.scala @@ -0,0 +1,101 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.omadataoauth2.ReportingType.ReportingType +import org.scalatra.ScalatraServlet + +import java.net.URLEncoder +import java.util.{Base64, UUID} + +trait OmaDataOAuth2Support extends ScalatraServlet with OmaDataOAuth2Config { + def urlEncode(str: String): String = URLEncoder.encode(str, "UTF-8") + + def base64UrlEncode(str: String): String = Base64.getUrlEncoder().encodeToString(str.getBytes("UTF-8")) + def base64UrlDecode(str: String): String = new String(Base64.getUrlDecoder().decode(str), "UTF-8") + + + protected def validateQueryParams(): Either[ValidationError, ClientInfo] = { + for { + clientInfo <- validateQueryClientParams + _ <- validateQueryOtherParams + } yield clientInfo + } + + protected def validateQueryClientParams(): Either[ValidationError, ClientInfo] = { + for { + clientId <- validateParamExistsOnce("client_id", ReportingType.ToResourceOwner) + redirectUri <- validateParamExistsOnce("redirect_uri", ReportingType.ToResourceOwner) + _ <- validateClientIdRekisteröity(clientId) + _ <- validateRedirectUriRekisteröityAnnetulleClientIdlle(clientId, redirectUri) + } yield (ClientInfo(clientId, redirectUri)) + } + + protected def validateQueryOtherParams(): Either[ValidationError, Unit] = { + // TODO: TOR-2210: tee muiden parametrien validoinnit, joiden virheistä voi/kuuluu raportoida redirect_uri:n kautta + // clientille asti. Esim. onko S256 code challenge annettu jne. + Right(Unit) + } + + private def validateParamExistsOnce(paramName: String, reportingType: ReportingType): Either[ValidationError, String] = { + multiParams(paramName).length match { + case 0 => Left(ValidationError(ValidationErrorType.invalid_client_data, s"${paramName} puuttuu query-parametreista", reportingType)) + case 1 => Right(params(paramName)) + case _ => Left(ValidationError(ValidationErrorType.invalid_client_data, s"${paramName} määritelty useammin kuin kerran", reportingType)) + } + } + + protected def validateClientIdRekisteröity(clientId: String): Either[ValidationError, String] = { + // TODO: TOR-2210: esim. koodisto voisi olla parempi source kuin konffitiedosto clientien tiedoille + if (hasConfigForClient(clientId)) { + Right(clientId) + } else { + Left(ValidationError(ValidationErrorType.invalid_client_data, s"${clientId} tuntematon client_id, ei rekisteröity", ReportingType.ToResourceOwner)) + } + } + + protected def createParamsString(params: Seq[(String, String)]): String = params.map { + case (name, value) => s"${name}=${urlEncode(value)}" + }.mkString("&") + + + private def validateRedirectUriRekisteröityAnnetulleClientIdlle(clientId: String, redirectUri: String): Either[ValidationError, Unit] = { + if (hasRedirectUri(clientId, redirectUri)) { + Right(Unit) + } else { + Left(ValidationError(ValidationErrorType.invalid_client_data, s"${redirectUri} ei rekisteröity clientille ${clientId}", ReportingType.ToResourceOwner)) + } + } +} + +object ValidationError { + def apply(clientError: ValidationErrorType, loggedMessage: String, reportingType: ReportingType): ValidationError = + ValidationError(s"omadataoauth2-error-${UUID.randomUUID()}", clientError, loggedMessage, reportingType) +} + +case class ValidationError( + errorId: String, + errorType: ValidationErrorType, + loggedMessage: String, + reportingType: ReportingType +) { + def getClientErrorParams = s"error=${errorType.toString}&error_id=${errorId}" + def getLoggedErrorMessage = s"${errorId}: ${loggedMessage}" +} + +sealed abstract class ValidationErrorType(val errorType: String) + +object ValidationErrorType { + final case object invalid_client_data extends ValidationErrorType("invalid_client_data") + final case object invalid_request extends ValidationErrorType("invalid_request") +} + +object ReportingType extends Enumeration { + type ReportingType = Value + val + ToResourceOwner, // virheestä kuuluu raportoida vain loppukäyttäjälle + ToRedirectUri = Value // virheestä kuuluu raportoida redirect_uri:n kautta +} + +case class ClientInfo( + clientId: String, + redirectUri: String +) diff --git a/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2BackendSpec.scala b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2BackendSpec.scala new file mode 100644 index 0000000000..bfa85de4bb --- /dev/null +++ b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2BackendSpec.scala @@ -0,0 +1,93 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.http.KoskiErrorCategory +import fi.oph.koski.koskiuser.{KoskiMockUser, MockUsers} + +import java.nio.charset.StandardCharsets + +class OmaDataOAuth2BackendSpec extends OmaDataOAuth2TestBase { + "authorization-server rajapinta" - { + "voi kutsua, kun on käyttöoikeudet" in { + postAuthorizationServer(MockUsers.omadataOAuth2Palvelukäyttäjä) { + verifyResponseStatusOk() + } + } + "ei voi kutsua ilman käyttöoikeuksia" in { + postAuthorizationServer(MockUsers.kalle) { + verifyResponseStatus(403, KoskiErrorCategory.forbidden.vainOmaDataOAuth2()) + } + } + + "redirect_uri" - { + "vaaditaan, että on sama, jos oli annettu myös autorisointikutsussa" in { + // TODO: TOR-2210 + } + + "ei vaadita, jos ei ollut mukana autorisointikutsussa" in { + // TODO: TOR-2210, jos tätä polkua tuetaan + } + } + + "kun optionaalinen client_id on annettu" - { + "voi kutsua, kun client on rekisteröity" in { + val user = MockUsers.omadataOAuth2Palvelukäyttäjä + postAuthorizationServer(user, clientId = Some(user.username)) { + verifyResponseStatusOk() + } + } + "ei voi kutsua, kun clientia ei ole rekisteröity" in { + val user = MockUsers.rekisteröimätönOmadataOAuth2Palvelukäyttäjä + postAuthorizationServer(user, clientId = Some(user.username)) { + // TODO: TOR-2210: oikeat error-koodit ja sisällöt, https://www.rfc-editor.org/rfc/rfc6749#section-5.2 + verifyResponseStatus(400) + } + } + + "ei voi kutsua, jos rekisteröity client_id ei vastaa käyttäjätunnusta" in { + val user = MockUsers.omadataOAuth2Palvelukäyttäjä + val vääräUser = MockUsers.rekisteröimätönOmadataOAuth2Palvelukäyttäjä + postAuthorizationServer(user, clientId = Some(vääräUser.username)) { + // TODO: TOR-2210: oikeat error-koodit ja sisällöt, https://www.rfc-editor.org/rfc/rfc6749#section-5.2 + verifyResponseStatus(400) + } + } + } + } + + "resource-server rajapinta" - { + "voi kutsua, kun on käyttöoikeudet" in { + postResourceServer(MockUsers.omadataOAuth2Palvelukäyttäjä) { + verifyResponseStatusOk() + } + + } + "ei voi kutsua ilman käyttöoikeuksia" in { + postResourceServer(MockUsers.kalle) { + verifyResponseStatus(403, KoskiErrorCategory.forbidden.vainOmaDataOAuth2()) + } + } + } + + + private def postAuthorizationServer[T](user: KoskiMockUser, grantType: String = "authorization_code", code: String = validDummyCode, clientId: Option[String] = None)(f: => T): T = { + post(uri = "api/omadata-oauth2/authorization-server", + body = createFormParametersBody(grantType, code, clientId), + headers = authHeaders(user) ++ formContent)(f) + } + + private def createFormParametersBody(grantType: String, code: String, client_id: Option[String]): Array[Byte] = { + val params = Seq( + ("grant_type", grantType), + ("code", code), + ) ++ client_id.toSeq.map(clientId => ("client_id", clientId)) + + createParamsString(params).getBytes(StandardCharsets.UTF_8) + } + + private def postResourceServer[T](user: KoskiMockUser, token: String = "dummy-access-token")(f: => T): T = { + val tokenHeaders = Map("X-Auth" -> s"Bearer ${token}") + post(uri = "api/omadata-oauth2/resource-server", headers = authHeaders(user) ++ tokenHeaders)(f) + } +} + + diff --git a/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ClientDetailsSpec.scala b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ClientDetailsSpec.scala new file mode 100644 index 0000000000..a96ffd0abd --- /dev/null +++ b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2ClientDetailsSpec.scala @@ -0,0 +1,45 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.henkilo.KoskiSpecificMockOppijat +import fi.oph.koski.http.KoskiErrorCategory +import fi.oph.koski.koskiuser.{KoskiMockUser, MockUsers} +import fi.oph.koski.{KoskiApplicationForTests, KoskiHttpSpec} +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.Base64 + +class OmaDataOAuth2ClientDetailsSpec extends AnyFreeSpec with KoskiHttpSpec with Matchers { + val app = KoskiApplicationForTests + + val validClientId = "oauth2client" + + "client-details route" - { + val clientDetailsUri = s"api/omadata-oauth2/resource-owner/client-details/${validClientId}" + + val hetu = KoskiSpecificMockOppijat.eero.hetu.get + + "Palauttaa 401, jos kansalainen ei ole kirjautunut" in { + get( + uri = clientDetailsUri + ) { + verifyResponseStatus(401) + } + } + + "Palauttaa vastauksen kirjautuneena" in { + get( + uri = clientDetailsUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatusOk() + // TODO: TOR-2210 vastauksen sisältö + } + } + } +} + + + diff --git a/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2FrontendSpec.scala b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2FrontendSpec.scala new file mode 100644 index 0000000000..30bc056cf0 --- /dev/null +++ b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2FrontendSpec.scala @@ -0,0 +1,501 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.henkilo.KoskiSpecificMockOppijat +import fi.oph.koski.{KoskiApplicationForTests} + +class OmaDataOAuth2FrontendSpec extends OmaDataOAuth2TestBase { + val app = KoskiApplicationForTests + + val hetu = KoskiSpecificMockOppijat.eero.hetu.get + + val tuntematonClientId = "loytymatonClientId" + val vääräRedirectUri = "/koski/omadata-oauth2/EI-OLE/debug-post-response" + + val validClientId = "oauth2client" + val validState = "internal state" + val validRedirectUri = "/koski/omadata-oauth2/debug-post-response" + + val validParams = Seq( + ("client_id", validClientId), + ("response_type", "code"), + ("response_mode", "form_post"), + ("redirect_uri", validRedirectUri), + ("code_challenge", "NjIyMGQ4NDAxZGM0ZDI5NTdlMWRlNDI2YWNhNjA1NGRiMjQyZTE0NTg0YzRmOGMwMmU3MzFkYjlhNTRlZTlmZA"), + ("code_challenge_method", "S256"), + ("state", validState), + ("scope", "HENKILOTIEDOT_SYNTYMAAIKA HENKILOTIEDOT_NIMI OPISKELUOIKEUDET_SUORITETUT_TUTKINNOT") + ) + + val validParamsString = createParamsString(validParams) + + "resource-owner authorize frontend -rajapinta" - { + val baseUri = "omadata-oauth2/authorize" + + "toimivilla parametreilla" - { + "kirjautumattomalla käyttäjällä" - { + "redirectaa login-sivulle, joka redirectaa sivulle, jossa parametrit base64url-enkoodattuna" in { + val serverUri = s"${baseUri}?${validParamsString}" + val expectedLoginUri = s"/koski/login/oppija?service=/koski/user/login?onSuccess=/koski/omadata-oauth2/cas-workaround/authorize/${base64UrlEncode(validParamsString)}" + + get( + uri = serverUri + ) { + verifyResponseStatus(302) + + response.header("Location") should include(expectedLoginUri) + } + } + } + "kirjautuneella käyttäjällä" - { + "palauttaa suostumuksen myöntämis -sivun" in { + val serverUri = s"${baseUri}?${validParamsString}" + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatusOk() + body should include("/koski/js/koski-omadataoauth2.js") + } + } + } + } + + "Suostumuksen myöntämis- ja datan haku-operaatiot audit-lokitetaan" in { + // TODO: TOR-2210 + } + + "error-query parametrilla näyttää virheilmoituksen käyttäjälle" in { + // TODO: TOR-2210 + } + + "viallisella client_id/redirect_uri:lla" - { + "kirjautuneella käyttäjällä" - { + "redirectaa käyttäjän samaan osoitteeseen query-parametreihin sisällytetyllä virheilmoituksella" - { + Seq("client_id", "redirect_uri").foreach(paramName => { + s"kun ${paramName} puuttuu" in { + val väärälläParametrilla = createParamsString(validParams.filterNot(_._1 == paramName)) + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + } + + s"kun ${paramName} on annettu useammin kuin kerran" in { + val validValue = validParams.toMap.get(paramName).get + val väärälläParametrilla = createParamsString(validParams :+ (paramName, validValue)) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + + } + }) + + "kun client_id on tuntematon" in { + val väärälläParametrilla = createParamsString((validParams.toMap + ("client_id" -> tuntematonClientId)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + } + + "kun redirect_uri ei ole annetun client_idn tallennettu redirect_uri" in { + val väärälläParametrilla = createParamsString((validParams.toMap + ("redirect_uri" -> vääräRedirectUri)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + } + } + } + + "kirjautumattomalla käyttäjällä" - { + "redirectaa käyttäjän samaan osoitteeseen query-parametreihin sisällytetyllä virheilmoituksella" - { + Seq("client_id", "redirect_uri").foreach(paramName => { + s"kun ${paramName} puuttuu" in { + val väärälläParametrilla = createParamsString(validParams.filterNot(_._1 == paramName)) + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + } + + s"kun ${paramName} on annettu useammin kuin kerran" in { + val validValue = validParams.toMap.get(paramName).get + val väärälläParametrilla = createParamsString(validParams :+ (paramName, validValue)) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + + } + }) + + "kun client_id on tuntematon" in { + val väärälläParametrilla = createParamsString((validParams.toMap + ("client_id" -> tuntematonClientId)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + } + + "kun redirect_uri ei ole annetun client_idn tallennettu redirect_uri" in { + val väärälläParametrilla = createParamsString((validParams.toMap + ("redirect_uri" -> vääräRedirectUri)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + val expectedErrorMessageRegexp = "error=invalid_client_data&error_id=omadataoauth2-error-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}".r + get( + uri = serverUri + ) { + verifyResponseStatus(302) + response.header("Location") should include regex(expectedErrorMessageRegexp) + } + } + } + } + } + + "valideilla client_id/redirect_uri:lla" - { + // TODO: TOR-2210: testaa kirjatuneena ja kirjautumatta, molemmat + "redirectaa virhetiedot palauttavalle post response -sivulle" - { + "kun on annettu duplikaattiparametreja" in { + // TODO: TOR-2210 + } + "kun response_type ei ole code" in { + // TODO: TOR-2210 + } + "kun response_mode ei ole form_post" in { + // TODO: TOR-2210 + } + "kun code_challenge_method ei ole S256" in { + // TODO: TOR-2210 + } + "kun scope on epävalidi" in { + // TODO: TOR-2210 + } + "kun scope ei ole annetulle clientille sallittu" in { + // TODO: TOR-2210 + } + "kun code_challenge ei ole validimuotoinen challenge" in { + // TODO: TOR-2210 + } + } + } + + } + + "resource-owner authorize -rajapinta" - { + val baseUri = "api/omadata-oauth2/resource-owner/authorize" + + "ei toimi ilman kirjautumista" in { + val serverUri = s"${baseUri}?${validParamsString}" + + get( + uri = serverUri + ) { + verifyResponseStatus(401) + } + } + + "palauttaa authorization code:n käyttäjän ollessa kirjautuneena" in { + val serverUri = s"${baseUri}?${validParamsString}" + + val expectedCode = validDummyCode + val expectedResultParamsString = createParamsString( + Seq( + ("client_id", validClientId), + ("redirect_uri", validRedirectUri), + ("state", validState), + ("code", expectedCode), + ) + ) + + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(302) + + response.header("Location") should include(s"/koski/user/logout?target=/koski/omadata-oauth2/cas-workaround/post-response/${base64UrlEncode(expectedResultParamsString)}") + } + } + + Seq("client_id", "redirect_uri").foreach(paramName => { + s"palauttaa 500 kun ${paramName} puuttuu" in { + val väärälläParametrilla = createParamsString(validParams.filterNot(_._1 == paramName)) + val serverUri = s"${baseUri}?${väärälläParametrilla}" + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + + s"palauttaa 500 kun ${paramName} annettu enemmän kuin kerran" in { + val validValue = validParams.toMap.get(paramName).get + val väärälläParametrilla = createParamsString(validParams :+ (paramName, validValue)) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + }) + + "palauttaa 500, jos kutsutaan epävalidilla client_id:llä" in { + val väärälläParametrilla = createParamsString((validParams.toMap + ("client_id" -> tuntematonClientId)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + + "palauttaa 500, jos kutsutaan epävalidilla redirect_uri:lla" in { + val väärälläParametrilla = createParamsString((validParams.toMap + ("redirect_uri" -> vääräRedirectUri)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + + "kun halutaan välittää error" - { + val validParamsWithError = + validParams ++ + Seq(("error", "access_denied")) + + Seq("client_id", "redirect_uri").foreach(paramName => { + s"palauttaa 500 kun ${paramName} puuttuu" in { + val väärälläParametrilla = createParamsString(validParamsWithError.filterNot(_._1 == paramName)) + val serverUri = s"${baseUri}?${väärälläParametrilla}" + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + + s"palauttaa 500 kun ${paramName} annettu enemmän kuin kerran" in { + val validValue = validParams.toMap.get(paramName).get + val väärälläParametrilla = createParamsString(validParamsWithError :+ (paramName, validValue)) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + }) + + "palauttaa 500, jos kutsutaan epävalidilla client_id:llä" in { + val väärälläParametrilla = createParamsString((validParamsWithError.toMap + ("client_id" -> tuntematonClientId)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + + "palauttaa 500, jos kutsutaan epävalidilla redirect_uri:lla" in { + val väärälläParametrilla = createParamsString((validParamsWithError.toMap + ("redirect_uri" -> vääräRedirectUri)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(500) + } + } + + "välittää virhetiedot clientille, jos client_id ja redirect_uri ovat kunnossa" in { + val serverUri = s"${baseUri}?${createParamsString(validParamsWithError)}" + + val expectedError = "access_denied" + val expectedResultParamsString = createParamsString( + Seq( + ("client_id", validClientId), + ("redirect_uri", validRedirectUri), + ("state", validState), + ("error", expectedError) + ) + ) + + get( + uri = serverUri, + headers = kansalainenLoginHeaders(hetu) + ) { + verifyResponseStatus(302) + + response.header("Location") should include(s"/koski/user/logout?target=/koski/omadata-oauth2/cas-workaround/post-response/${base64UrlEncode(expectedResultParamsString)}") + } + } + + } + + "TODO, virhetilanteessa X palautetaan callbackiin viesti Y" in { + // TODO: TOR-2210 + } + } + + "post-response -rajapinta" - { + val baseUri = "omadata-oauth2/post-response" + + val validPostResponseParams =Seq( + ("client_id", validClientId), + ("redirect_uri", validRedirectUri), + ("state", validState), + ("code", validDummyCode) + ) + + Seq("client_id", "redirect_uri").map(paramName => { + s"palauttaa 500 kun ${paramName} puuttuu" in { + val väärälläParametrilla = createParamsString(validPostResponseParams.filterNot(_._1 == paramName)) + val serverUri = s"${baseUri}?${väärälläParametrilla}" + get( + uri = serverUri + ) { + verifyResponseStatus(500) + } + } + + s"palauttaa 500 kun ${paramName} annettu enemmän kuin kerran" in { + val validValue = validParams.toMap.get(paramName).get + val väärälläParametrilla = createParamsString(validPostResponseParams :+ (paramName, validValue)) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + get( + uri = serverUri + ) { + verifyResponseStatus(500) + } + } + }) + + "palauttaa 500, jos kutsutaan epävalidilla client_id:llä" in { + val väärälläParametrilla = createParamsString((validPostResponseParams.toMap + ("client_id" -> tuntematonClientId)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + get( + uri = serverUri + ) { + verifyResponseStatus(500) + } + } + + "palauttaa 500, jos kutsutaan epävalidilla redirect_uri:lla" in { + val väärälläParametrilla = createParamsString((validPostResponseParams.toMap + ("redirect_uri" -> vääräRedirectUri)).toSeq) + + val serverUri = s"${baseUri}?${väärälläParametrilla}" + + get( + uri = serverUri + ) { + verifyResponseStatus(500) + } + } + + "välittää coden clientille, kun client_id ja redirect_uri ovat kunnossa" in { + val expectedCode = validDummyCode + + val serverUri = s"${baseUri}?${createParamsString(validPostResponseParams)}" + + get( + uri = serverUri + ) { + verifyResponseStatus(200) + + body should include(expectedCode) + body should include(validState) + } + } + + "välittää virhetiedot clientille, kun client_id ja redirect_uri ovat kunnossa" in { + val expectedError = "access_denied" + + val paramsWithError = ( + validPostResponseParams.toMap - "code" + ("error" -> expectedError) + ).toSeq + + val serverUri = s"${baseUri}?${createParamsString(paramsWithError)}" + + get( + uri = serverUri + ) { + verifyResponseStatus(200) + + body should include(expectedError) + body should include(validState) + } + } + } +} + + diff --git a/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Spec.scala b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Spec.scala deleted file mode 100644 index 404b3015d3..0000000000 --- a/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2Spec.scala +++ /dev/null @@ -1,58 +0,0 @@ -package fi.oph.koski.omadataoauth2 - -import fi.oph.koski.http.KoskiErrorCategory -import fi.oph.koski.koskiuser.{KoskiMockUser, MockUsers} -import fi.oph.koski.{KoskiApplicationForTests, KoskiHttpSpec} -import org.scalatest.freespec.AnyFreeSpec -import org.scalatest.matchers.should.Matchers - -import java.nio.charset.StandardCharsets - -class OmaDataOAuth2Spec extends AnyFreeSpec with KoskiHttpSpec with Matchers { - val app = KoskiApplicationForTests - - "authorization-server rajapinta" - { - "voi kutsua, kun on käyttöoikeudet" in { - postAuthorizationServer(MockUsers.omadataOAuth2Palvelukäyttäjä) { - verifyResponseStatusOk() - } - } - "ei voi kutsua ilman käyttöoikeuksia" in { - postAuthorizationServer(MockUsers.kalle) { - verifyResponseStatus(403, KoskiErrorCategory.forbidden.vainOmaDataOAuth2()) - } - } - } - - - "resource-server rajapinta" - { - "voi kutsua, kun on käyttöoikeudet" in { - postResourceServer(MockUsers.omadataOAuth2Palvelukäyttäjä) { - verifyResponseStatusOk() - } - - } - "ei voi kutsua ilman käyttöoikeuksia" in { - postResourceServer(MockUsers.kalle) { - verifyResponseStatus(403, KoskiErrorCategory.forbidden.vainOmaDataOAuth2()) - } - } - } - - private def postAuthorizationServer[T](user: KoskiMockUser, grantType: String = "authorization_code", code: String = "foobar")(f: => T): T = { - post(uri = "api/omadata-oauth2/authorization-server", - body = createFormParametersBody(grantType, code), - headers = authHeaders(user) ++ formContent)(f) - } - - private def createFormParametersBody(grantType: String, code: String): Array[Byte] = { - s"grant_type=${grantType}&code=${code}".getBytes(StandardCharsets.UTF_8) - } - - private def postResourceServer[T](user: KoskiMockUser, token: String = "dummy-access-token")(f: => T): T = { - val tokenHeaders = Map("X-Auth" -> s"Bearer ${token}") - post(uri = "api/omadata-oauth2/resource-server", headers = authHeaders(user) ++ tokenHeaders)(f) - } -} - - diff --git a/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2TestBase.scala b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2TestBase.scala new file mode 100644 index 0000000000..f3104d4002 --- /dev/null +++ b/src/test/scala/fi/oph/koski/omadataoauth2/OmaDataOAuth2TestBase.scala @@ -0,0 +1,21 @@ +package fi.oph.koski.omadataoauth2 + +import fi.oph.koski.KoskiHttpSpec +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +import java.net.URLEncoder +import java.util.Base64 + +class OmaDataOAuth2TestBase extends AnyFreeSpec with KoskiHttpSpec with Matchers { + + val validDummyCode = "foobar" + + protected def urlEncode(str: String): String = URLEncoder.encode(str, "UTF-8") + protected def base64UrlEncode(str: String): String = Base64.getUrlEncoder().encodeToString(str.getBytes("UTF-8")) + protected def base64UrlDecode(str: String): String = new String(Base64.getUrlDecoder().decode(str), "UTF-8") + + protected def createParamsString(params: Seq[(String, String)]): String = params.map { + case (name, value) => s"${name}=${urlEncode(value)}" + }.mkString("&") +} diff --git a/web/app/omadata/HyvaksyntaLanding.jsx b/web/app/omadata/HyvaksyntaLanding.jsx index 25bdf1844e..473b15b276 100644 --- a/web/app/omadata/HyvaksyntaLanding.jsx +++ b/web/app/omadata/HyvaksyntaLanding.jsx @@ -81,7 +81,6 @@ class HyvaksyntaLanding extends React.Component { } }) } - getLogoutURL() { return `/koski/user/logout?target=${ window.location.origin diff --git a/web/app/omadata/OmaDataOAuth2AnnaHyvaksynta.jsx b/web/app/omadata/OmaDataOAuth2AnnaHyvaksynta.jsx new file mode 100644 index 0000000000..84c18d4c3f --- /dev/null +++ b/web/app/omadata/OmaDataOAuth2AnnaHyvaksynta.jsx @@ -0,0 +1,114 @@ +import React from 'baret' +import Text from '../i18n/Text' +import(/* webpackChunkName: "styles" */ '../style/main.less') + +export default ({ memberName, onAcceptClick, onDeclineClick }) => ( +
+
+
+ + +
+
+ +
+
+ + {':'} +
    +
  • + + {':'} +
      +
    • + +
    • +
    • + +
    • +
    +
  • +
  • + + {':'} +
      +
    • + +
    • +
    • + +
    • +
    +
  • +
  • + +
  • +
+
+
+

+ +

+

+ + + +

+

+ +

+

+ +

+

+ + + +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ + + +

+

+ +

+

+ + + +

+
+
+
+ + +
+
+) diff --git a/web/app/omadata/OmaDataOAuth2HyvaksyntaLanding.jsx b/web/app/omadata/OmaDataOAuth2HyvaksyntaLanding.jsx new file mode 100644 index 0000000000..e277e8ea74 --- /dev/null +++ b/web/app/omadata/OmaDataOAuth2HyvaksyntaLanding.jsx @@ -0,0 +1,112 @@ +import '../polyfills/polyfills.js' +import React from 'baret' +import ReactDOM from 'react-dom' +import ErrorPage from './ErrorPage' +import Spinner from './Spinner' +import Footer from './Footer' +import Header from './Header' +import Http from '../util/http' +import { currentLocation, parseQuery } from '../util/location' +import { Error as ErrorDisplay, logError } from '../util/Error' +import OmaDataOAuth2UusiHyvaksynta from "./OmaDataOAuth2UusiHyvaksynta" +__webpack_nonce__ = window.nonce + + +class OmaDataOAuth2HyvaksyntaLanding extends React.Component { + constructor(props) { + super(props) + + this.state = { + loading: true, + client_id: this.parseClientId(), + scope: this.parseScope(), + error: undefined, + clientName: undefined + } + + this.authorizeClient = this.authorizeClient.bind(this) + this.declineClient = this.declineClient.bind(this) + } + + parseClientId() { + const clientId = parseQuery(currentLocation().queryString).client_id + + // TODO TOR-2210: Tarkista, että client_id on jokin olemassaoleva? Tämän voinee tehdä tässä jo ennen valtuutusta, vai tehdäänkö bäkkärissä, ja tässä vaan luotetaan? + return clientId + } + + parseScope() { + return parseQuery(currentLocation().queryString).scope + } + + componentDidMount() { + try { + Http.cachedGet(`/koski/api/omadata-oauth2/resource-owner/client-details/${this.state.client_id}`, { + errorHandler: (e) => { + logError(e) + this.setState({ loading: false }) + } + }).onValue((client) => + this.setState({ + clientName: client.name, + loading: false + }) + ) + } catch (error) { + logError(error) + this.setState({ loading: false }) + } + + // TODO: TOR-2210: tässä voisi hakea myös scope detailsit ja välittää eteenpäin selväkielisinä merkkijonoina rendattavaksi + } + + authorizeClient() { + // TODO: TOR-2210 Pitäisikö parametreista tässä filtteröidä pois muut kuin ne, mistä backend on kiinnostunut? + let params = new URL(document.location.toString()).searchParams + + window.location.href = `/koski/api/omadata-oauth2/resource-owner/authorize?${params.toString()}` + } + + declineClient() { + // TODO: TOR-2210 Pitäisikö parametreista tässä filtteröidä pois muut kuin ne, mistä backend on kiinnostunut? + let params = new URL(document.location.toString()).searchParams + params.set('error', 'access_denied') // TODO: TOR-2210: tämä on standardinmukainen minimivirhe, pitäisikö lisätä detskuja? https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1 + + window.location.href = `/koski/api/omadata-oauth2/resource-owner/authorize?${params.toString()}` + } + + render() { + const error = this.state.error ? ( + + ) : null + + return ( +
+
+ {error} + + {this.state.clientName ? ( + + ) : this.state.loading ? ( + + ) : ( + + )} + +
+
+ ) + } +} + +ReactDOM.render( +
+ +
, + document.getElementById('content') +) diff --git a/web/app/omadata/OmaDataOAuth2UusiHyvaksynta.jsx b/web/app/omadata/OmaDataOAuth2UusiHyvaksynta.jsx new file mode 100644 index 0000000000..d129747a48 --- /dev/null +++ b/web/app/omadata/OmaDataOAuth2UusiHyvaksynta.jsx @@ -0,0 +1,48 @@ +import React from 'baret' +import { userP } from '../util/user' +import Http from '../util/http' +import Text from '../i18n/Text' +import { formatFinnishDate, parseISODate } from '../date/date' +import { getBirthdayFromEditorRes } from '../util/util' +import OmaDataOAuth2AnnaHyvaksynta from "./OmaDataOAuth2AnnaHyvaksynta"; + +const editorP = Http.cachedGet('/koski/api/omattiedot/editor', { + errorMapper: () => undefined +}).toProperty() + +const getBirthDate = (editorResponse) => { + if (!editorResponse) return + + return formatFinnishDate( + parseISODate(getBirthdayFromEditorRes(editorResponse)) + ) +} + +export default ({ + clientName, + scope, + onAuthorization, + onDecline +}) => ( +
+
+

+ +

+
+
+
{userP.map((user) => user && user.name)}
+
+ {' '} + {editorP.map((s) => 's. ' + getBirthDate(s))} +
+
+ + onAuthorization()} + onDeclineClick={() => onDecline()} + /> +
+) diff --git a/web/app/style/main.less b/web/app/style/main.less index 7fb27c7df4..de22f9f833 100644 --- a/web/app/style/main.less +++ b/web/app/style/main.less @@ -49,6 +49,7 @@ @import 'ei-suorituksia'; @import 'varoitukset'; @import 'omadata'; +@import 'omadataoauth2'; @import 'kayttoluvat'; @import 'raportit'; @import 'kela-virkailija'; diff --git a/web/app/style/omadataoauth2.less b/web/app/style/omadataoauth2.less new file mode 100644 index 0000000000..dbff5b9937 --- /dev/null +++ b/web/app/style/omadataoauth2.less @@ -0,0 +1,364 @@ +@import 'colors.less'; +@import 'mixins.less'; + +@element-header-height: 80px; +@margin-mobile-left: 5%; + +.omadataoauth2-page { + background-color: @color-white; + + a { + text-decoration: underline; + } + + .header { + display: flex; + height: @element-header-height; + background-color: @color-link-blue; + color: @color-white; + justify-content: space-between; + align-items: center; + + font-style: normal; + font-stretch: normal; + font-weight: normal; + line-height: normal; + letter-spacing: normal; + + @media @media-phone { + justify-content: flex-start; + } + + .lang { + margin: auto; + + @media @media-phone { + margin-right: auto; + } + + .change-lang button { + background: Transparent no-repeat; + border: none; + cursor: pointer; + overflow: hidden; + color: white; + font-weight: 600; + font-family: inherit; + padding-right: 5px; + } + } + + .title { + display: flex; + align-items: center; + font-size: 24px; + font-weight: bold; + margin: auto; + @media @media-phone { + margin: auto; + } + + img { + height: 44px; + width: 44px; + margin-right: 20px; + + @media @media-phone { + display: none; + } + } + } + + @media @media-phone { + .user { + display: none; + } + } + + .username { + display: flex; + font-size: 16px; + font-weight: bold; + align-items: center; + } + + img { + height: 18px; + width: 18px; + margin-right: 20px; + } + } + .acceptance-container { + color: @color-black; + margin-bottom: 64px; + + @media @media-phone { + margin: 0 5% 64px 5%; + } + + @media @media-full { + max-width: 730px; + margin: 0 auto 64px auto; + } + + h1 { + margin-top: 46px; + margin-bottom: 39px; + } + + .user { + display: flex; + flex-direction: row; + margin-bottom: 20px; + + @media @media-phone { + flex-direction: column; + } + + .username { + font-size: 16px; + font-weight: bold; + } + + .dateofbirth { + font-size: 16px; + font-weight: 300; + letter-spacing: 0.2px; + + @media @media-full { + margin-left: 1ch; + } + } + } + + .acceptance-success-box { + border: 1px @color-link-blue solid; + padding: 35px 10% 50px 10%; + + @media @media-phone { + text-align: center; + } + + @media @media-full { + max-width: 730px; + } + + .acceptance-control-mydata { + @media @media-phone { + font-size: 14px; + font-weight: 300; + } + } + + .success-container { + display: flex; + align-items: center; + + @media @media-phone { + flex-direction: column; + } + + .acceptance-title-success { + font-size: 24px; + font-weight: 600; + color: @color-black; + text-transform: uppercase; + + @media @media-phone { + font-size: 18px; + font-weight: 600; + } + } + + .acceptance-image { + height: 40px; + width: 40px; + + @media @media-full { + margin-left: -60px; + margin-right: 20px; + } + @media @media-phone { + margin-bottom: 20px; + } + } + } + } + + .acceptance-box { + border: 1px @color-link-blue solid; + padding: 24px 5%; + + @media @media-phone { + width: 100%; + padding: 16px; + position: center; + } + } + + .acceptance-title { + font-size: 14px; + letter-spacing: 0.3px; + text-align: left; + + @media @media-phone { + font-size: 12px; + } + } + + .acceptance-control-mydata { + font-size: 20px; + letter-spacing: 0.3px; + color: @color-black; + margin-top: 15px; + } + + .acceptance-member-name { + font-size: 24px; + font-weight: 600; + text-align: left; + color: @color-link-blue; + margin: 24px 0; + + @media @media-phone { + font-size: 15px; + } + } + + .acceptance-share-info { + font-size: 15px; + line-height: 1.38; + text-align: left; + + ul { + -webkit-padding-start: 24px; + -webkit-margin-before: 0; + + li { + list-style-type: disc; + margin: 8px; + + > li { + font-size: 12px; + } + } + } + } + + .acceptance-paragraphs { + margin-top: 28px; + } + + .acceptance-return-container { + .acceptance-return-automatically { + font-size: 14px; + font-weight: bold; + color: @color-link-blue; + margin: 16px 0; + } + } + + .acceptance-button-container { + position: fixed; + left: 0; + bottom: 0; + width: 100%; + background-color: @color-link-blue; + text-align: center; + + @media @media-phone { + font-size: 16px; + align-items: center; + } + + .koski-button { + border: 2px solid @color-white; + } + + .koski-button:focus { + background-color: @color-dark-blue; + } + + .acceptance-button { + margin-top: 16px; + width: 209px; + font-size: 18px; + font-weight: 600; + + @media @media-phone { + width: 72.6%; + margin-bottom: 16px; + } + } + + .decline-button { + font-size: 18px; + margin-bottom: 4px; + + background: none; + border: none; + padding: 0; + text-decoration: underline; + + @media @media-phone { + font-size: 14px; + } + + @media @media-full { + margin: 4px 0; + } + } + } + } + + .footer { + display: flex; + height: 112px; + justify-content: space-between; + margin-bottom: 124px; + border-top: 2px @color-link-blue solid; + padding-top: 34px; + + @media @media-full { + height: 154px; + justify-content: center; + } + + @media screen and (max-width: 560px) { + margin-left: 5px; + margin-right: 5px; + } + + img { + max-width: 50%; + } + + img:not(:first-child) { + @media @media-full { + margin-left: 43px; + } + } + } + + // Error page styles + + .loading-container { + display: flex; + align-items: center; + flex-direction: column; + text-align: center; + font-size: 20px; + margin-top: 30px; + } + + .error-container { + display: flex; + align-items: center; + flex-direction: column; + } + .error-text { + font-size: 20px; + letter-spacing: 0.3px; + margin: 60px 0 100px 0; + } +} diff --git a/web/webpack.config.js b/web/webpack.config.js index 386a3a6421..6a9473b8d2 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -15,6 +15,7 @@ module.exports = { pulssi: './app/Pulssi.jsx', lander: './app/Lander.jsx', omadata: './app/omadata/HyvaksyntaLanding.jsx', + omadataoauth2: './app/omadata/OmaDataOAuth2HyvaksyntaLanding.jsx', eisuorituksia: './app/EiSuorituksia.jsx', korhopankki: './app/Korhopankki.jsx', kayttooikeudet: './app/Kayttooikeudet.jsx'