diff --git a/build.sbt b/build.sbt index f1e5071..960da2f 100644 --- a/build.sbt +++ b/build.sbt @@ -18,7 +18,7 @@ ThisBuild / organization := "app.softnetwork" name := "account" -ThisBuild / version := "0.8.7" +ThisBuild / version := "0.8.8" ThisBuild / scalaVersion := scala212 diff --git a/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala b/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala index 7ef1bb5..fc19298 100644 --- a/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala +++ b/common/src/main/scala/app/softnetwork/account/config/AccountSettings.scala @@ -15,6 +15,20 @@ object AccountSettings extends StrictLogging { val Realm: String = config.getString("auth.realm") + /** Session key holding the account's e-mail, stamped at sign-in by every authentication path. + * + * Downstream services derive trust decisions from the e-mail DOMAIN — the licence server keys + * its one-evaluation-per-organisation rule and its free-mail gate on it. Their only other source + * is an organisation contact address the requester can edit, i.e. forge. Putting the address + * here places it behind the session's HMAC, which is the same trust the `admin` flag already + * relies on for authorization. + * + * Not a config value: it is a wire contract with those readers, so it must not be re-mappable + * per deployment. Absent for an account registered by phone (`Account.email` is an `Option`) — + * readers must handle that rather than assume presence. + */ + val SessionEmailKey: String = "email" + val Path: String = config.getString("auth.path") val BaseUrl: String = config.getString("auth.baseUrl") diff --git a/core/src/main/scala/app/softnetwork/account/service/AccountService.scala b/core/src/main/scala/app/softnetwork/account/service/AccountService.scala index 4d67d4a..4ecf48f 100644 --- a/core/src/main/scala/app/softnetwork/account/service/AccountService.scala +++ b/core/src/main/scala/app/softnetwork/account/service/AccountService.scala @@ -182,6 +182,10 @@ trait AccountService[PV <: ProfileView, DV <: AccountDetailsView, AV <: AccountV session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } setSession(sc, st, session) { // create a new anti csrf token setNewCsrfToken(checkHeader) { @@ -224,6 +228,10 @@ trait AccountService[PV <: ProfileView, DV <: AccountDetailsView, AV <: AccountV session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } setSession(sc, st, session) { // create a new anti csrf token setNewCsrfToken(checkHeader) { diff --git a/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala b/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala index 09fb27f..694edc3 100644 --- a/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/account/service/AccountServiceEndpoints.scala @@ -248,6 +248,10 @@ trait AccountServiceEndpoints[SU, SD <: SessionData with SessionDataDecorator[SD session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } Right((account.view.asInstanceOf[AV], Some(session))) case other => Left(resultToApiError(other)) } @@ -313,6 +317,10 @@ trait AccountServiceEndpoints[SU, SD <: SessionData with SessionDataDecorator[SD session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } Right( ( ( diff --git a/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala b/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala index 0591554..e6ba488 100644 --- a/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala +++ b/core/src/main/scala/app/softnetwork/account/service/OAuthService.scala @@ -165,6 +165,10 @@ trait OAuthService[SD <: SessionData with SessionDataDecorator[SD]] session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } setSession(sc, st, session) { // create a new anti csrf token setNewCsrfToken(checkHeader) { diff --git a/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala b/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala index 709221b..804ac99 100644 --- a/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala +++ b/core/src/main/scala/app/softnetwork/account/service/OAuthServiceEndpoints.scala @@ -222,6 +222,10 @@ trait OAuthServiceEndpoints[SD <: SessionData with SessionDataDecorator[SD]] session += (session.profileKey, profile.name) case _ => } + account.email match { + case Some(email) => session += (AccountSettings.SessionEmailKey, email) + case _ => + } Right( account.details .map(d => Me(d.firstName, d.lastName, d.email)) diff --git a/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala b/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala index 9a615e4..d854b5b 100644 --- a/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala +++ b/testkit/src/main/scala/app/softnetwork/account/scalatest/AccountRouteSpec.scala @@ -313,6 +313,24 @@ trait AccountRouteSpec[ responseAs[AV].status shouldBe AccountStatus.Active } } + // Downstream services key trust decisions on the DOMAIN of this address — the licence server + // decides one-evaluation-per-organisation and its free-mail gate on it. Their only other source + // is an organisation contact the requester can edit, i.e. forge, so the stamp is a contract + // rather than a convenience. It is asserted at the SESSION because an authentication path that + // forgets it degrades silently: the reader just falls back. + // (The e-mail account is activated by "work with matching email and password" above.) + "stamp the account e-mail into the session" in { + // No trailing signOut(): `signIn` opens with one, and calling it twice leaves stale + // headers that make the NEXT sign-in 403. + signIn(email, password) + extractSession().flatMap(_.get(AccountSettings.SessionEmailKey)) shouldBe Some(email) + } + // `Account.email` is an Option — an account registered by phone has none. The key must then be + // ABSENT rather than empty, so a reader can tell "no address" from "an address we lost". + "leave the e-mail absent for an account that has none" in { + signIn(gsm, password) + extractSession().flatMap(_.get(AccountSettings.SessionEmailKey)) shouldBe None + } "fail with unknown username" in { Post( s"/$RootPath/${AccountSettings.Path}/login",