Skip to content

Commit

Permalink
Toteuta frontend-polku minimitarkistuksin;
Browse files Browse the repository at this point in the history
Backendin toteutus vielä puuttuu, mutta client_id:n ja redirect_uri:n oikeellisuudesta pidetään huolta.
  • Loading branch information
AleksiAhtiainen committed Oct 11, 2024
1 parent d9d81c5 commit e9fc2f3
Show file tree
Hide file tree
Showing 25 changed files with 2,225 additions and 90 deletions.
6 changes: 4 additions & 2 deletions omadata-oauth2-sample/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ async function handleAccessTokenRequestMTLS(

const body = new URLSearchParams({
grant_type: 'authorization_code',
code: 'foobar'
code: 'foobar',
code_verifier: 'barfoobar'
}).toString()

// TODO: poista authorization coden debuggaus
Expand Down Expand Up @@ -254,7 +255,8 @@ async function handleAccessTokenRequestBasicAuth(
headers: myHeaders,
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'foobar'
code: 'foobar',
code_verifier: 'barfoobar'
}).toString()
})
return response
Expand Down
27 changes: 27 additions & 0 deletions src/main/resources/reference.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
}
Expand Down
11 changes: 8 additions & 3 deletions src/main/scala/ScalatraBootstrap.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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)) {
Expand Down
14 changes: 13 additions & 1 deletion src/main/scala/fi/oph/koski/koskiuser/MockUsers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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ä",
Expand Down Expand Up @@ -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ä
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -13,51 +12,120 @@ 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(";"))

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: TOR-2210 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}"))
}
}
}
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?
"code_verifier" -> label("code_verifier", text(required)),
"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,
code_verifier: String,
redirect_uri: Option[String],
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
)
// refresh_token: Option[String] // TODO: TOR-2210: jos tarvitaan refresh token
// scope: Option[String] // TOD: TOR-2210: 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package fi.oph.koski.omadataoauth2

import fi.oph.koski.config.{Environment, KoskiApplication}
import fi.oph.koski.frontendvalvonta.FrontendValvontaMode
import fi.oph.koski.koskiuser.Unauthenticated
import fi.oph.koski.servlet.{KoskiSpecificApiServlet, NoCache}

// 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 KoskiSpecificApiServlet with OmaDataOAuth2Support with Unauthenticated with NoCache
{
val allowFrameAncestors: Boolean = !Environment.isServerEnvironment(application.config)
val frontendValvontaMode: FrontendValvontaMode.FrontendValvontaMode =
FrontendValvontaMode(application.config.getString("frontend-valvonta.mode"))

get("/authorize/:base64UrlEnkoodattuPaluuosoitteenParametrilista") {
val decodedParameters = base64UrlDecode(params("base64UrlEnkoodattuPaluuosoitteenParametrilista"))
val decodedUrl = s"/koski/omadata-oauth2/authorize?${decodedParameters}"

redirect(decodedUrl)
}

get("/post-response/:base64UrlEnkoodattuPaluuosoitteenParametrilista") {
val decodedParameters = base64UrlDecode(params("base64UrlEnkoodattuPaluuosoitteenParametrilista"))
val decodedUrl = s"/koski/omadata-oauth2/post-response?${decodedParameters}"

redirect(decodedUrl)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}

}
Original file line number Diff line number Diff line change
@@ -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.errorDescription}")
halt(500)
case Right(ClientInfo(clientId, redirectUri, state)) =>
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"
)

<html lang={lang}>
<head>
<title>
Submit This Form
</title>
<script nonce={nonce}>
<!-- Workaround to autosubmit form after loading, since nonce cannot be specified for onload eventhandler. -->
{jsAtom"const s = document.createElement('script'); s.src = '/koski/empty.js'; s.onload = () => { document.forms[0].submit(); }; document.documentElement.appendChild(s); "}
</script>
</head>
<body>
<form method="post" action={redirectUri}>
{inputParams.map(renderInputIfParameterDefined)}
</form>
</body>
</html>
}
})

private def renderInputIfParameterDefined(paramName: String) = {
multiParams(paramName).headOption.map(v => <input type="hidden" name={paramName} value={v}/>).getOrElse(NodeSeq.Empty)
}
}
Loading

0 comments on commit e9fc2f3

Please sign in to comment.