CVE-2021-25981 (Scala, published 2022-01-03T07:15Z)
Description: value: In Talkyard, regular versions v0.2021.20 through v0.2021.33 and dev versions v0.2021.20 through v0.2021.34, are vulnerable to Insufficient Session Expiration. This may allow an attacker to reuse the admin’s still-valid session token even when logged-out, to gain admin privileges, given the attacker is able to obtain that token (via other, hypothetical attacks)
Severity: HIGH (CVSS v3 base score: 9.8)
Weakness: CWE-613 \u2014 Insufficient Session Expiration
According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
Repository: https://github.com/debiki/talkyard
Files changed:
appsv/model/src/main/scala/com/debiki/core/Prelude.scala
appsv/model/src/main/scala/com/debiki/core/Site.scala
appsv/model/src/main/scala/com/debiki/core/SiteTransaction.scala
appsv/model/src/main/scala/com/debiki/core/SystemTransaction.scala
appsv/model/src/main/scala/com/debiki/core/TySession.scala
appsv/model/src/main/scala/com/debiki/core/package.scala
appsv/rdb/src/main/scala/com/debiki/dao/rdb/CreateSiteSystemDaoMixin.scala
appsv/rdb/src/main/scala/com/debiki/dao/rdb/Rdb.scala
appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSiteTransaction.scala
appsv/rdb/src/main/scala/com/debiki/dao/rdb/RdbSystemTransaction.scala
appsv/rdb/src/main/scala/com/debiki/dao/rdb/SessionsRdbMixin.scala
appsv/server/controllers/DebugTestController.scala
appsv/server/controllers/DraftsController.scala
appsv/server/controllers/EditController.scala
appsv/server/controllers/EmbeddedTopicsController.scala
appsv/server/controllers/FlagController.scala
appsv/server/controllers/ImpersonateController.scala
appsv/server/controllers/InviteController.scala
appsv/server/controllers/LoginAsGuestController.scala
appsv/server/controllers/LoginController.scala
appsv/server/controllers/LoginWithOpenAuthController.scala
appsv/server/controllers/LoginWithPasswordController.scala
appsv/server/controllers/ReplyController.scala
appsv/server/controllers/ResetPasswordController.scala
appsv/server/controllers/UserController.scala
appsv/server/controllers/ViewPageController.scala
appsv/server/controllers/VoteController.scala
appsv/server/debiki/JsonUtils.scala
appsv/server/debiki/ReactJson.scala
appsv/server/debiki/dao/SiteDao.scala
appsv/server/debiki/dao/SystemDao.scala
appsv/server/debiki/dao/UserDao.scala
appsv/server/ed/server/EdAppLoader.scala
appsv/server/ed/server/EdController.scala
appsv/server/ed/server/auth/AuthzSiteDaoMixin.scala
appsv/server/ed/server/http/DebikiRequest.scala
appsv/server/ed/server/http/PageRequest.scala
appsv/server/ed/server/http/PlainApiActions.scala
appsv/server/ed/server/http/package.scala
appsv/server/ed/server/jobs/Janitor.scala
appsv/server/ed/server/pubsub/SubscriberController.scala
appsv/server/ed/server/security/package.scala
appsv/server/talkyard/server/JsX.scala
appsv/server/talkyard/server/api/QueryDoController.scala
appsv/server/talkyard/server/authn/SsoAuthnController.scala
appsv/server/talkyard/server/authn/package.scala
appsv/server/talkyard/server/http/package.scala
appsv/server/talkyard/server/sess/SessionController.scala
appsv/server/talkyard/server/sess/SessionSiteDaoMixin.scala
client/app-head/head-bundle.ts
client/app-more/login/login-dialog.more.ts
client/app-more/topbar/my-menu.more.ts
client/app-more/users/user-preferences.more.ts
client/app-slim/ReactActions.ts
client/app-slim/Server.ts
client/app-slim/if-in-iframe.ts
client/app-slim/login/login-if-needed.ts
client/app-slim/me-getters.ts
client/app-slim/model.ts
client/app-slim/oop-methods.ts
client/app-slim/prelude.ts
client/app-slim/slim-bundle.d.ts
client/app-slim/start-page.ts
client/app-slim/translations.d.ts
client/embedded-comments/blog-comments.ts
client/types-and-const-enums.ts
conf/routes
docs/maybe-do-later.txt
docs/naming-notes.md
docs/tests-map.txt
s/impl/tyd-e2e-tests.ts
s/run-e2e-tests.sh
tests/app/debiki/dao/UserStatsAppSpec.scala
tests/e2e-wdio7/specs/d.sessions-logout-elsewhere.4br.e2e.ts
tests/e2e-wdio7/specs/d.sessions-staff-logout-others.4br.e2e.ts
tests/e2e-wdio7/specs/embcom.expimpjson.import-tests-impl.ts
tests/e2e-wdio7/specs/embcom.sessions-emb-sess-cannot-moderate.3br.e2e.ts
tests/e2e-wdio7/test-types.ts
tests/e2e-wdio7/test-types2.ts
tests/e2e-wdio7/utils/settings-exp-def.ts
tests/e2e-wdio7/utils/ty-e2e-test-browser.ts
tests/e2e-wdio7/wdio.conf.ts
tests/e2e/specs/admin-user-suspend.2browsers.test.ts
tests/e2e/specs/sso-test.2browsers.test.ts
translations/en_US/i18n.ts
Commit message: Better session ids, 5 parts, feature flag to enable.


Vulnerable code:
import play.api.libs.json.{JsNumber, JsObject, JsString}
  def warnDbgDieIf(test: => Bo, errorCode: St, details: St): U = {
  /** Generates a 130 bit string, almost 26 chars long since each char in a 32 chars
    * alphabet has 5 bits (but we use 36 chars here).
  def nextRandomString(): String =
    new java.math.BigInteger(130, _random).toString(36) // 0..9, a..z
  SECURITY; COULD // use SHA-256 instead.
  private def mdSha1 = js.MessageDigest.getInstance("SHA-1") // not thread safe
  def anyWeirdJsObjField(obj: JsObject, maxLength: Int): Option[String] = {
      if (fieldName.isEmpty) return Some("Empty field name")
      if (!fieldName.isOkVariableName) return Some(s"Weird field name: $fieldName")
      if (fieldName.length > maxLength) return Some(s"Too long field name: $fieldName")
          if (s.value.isEmpty) return Some(s"Empty value for field $fieldName")
          if (!s.value.isOkVariableName) return Some(s"Bad value for field $fieldName: $fieldValue")
          if (s.value.length > maxLength) return Some(s"Too long field value, $fieldName: $fieldValue")
          return Some(s"Value of field $fieldName is weird")
) {
  def isTestSite: Bo = id <= Site.MaxTestSiteId
  ) extends SiteIdHostnames with SiteLimitsMultipliers {
  def isTestSite: Bo = id <= MaxTestSiteId

  def logBrowserErrors: Action[JsValue] = PostJsonAction(RateLimits.BrowserError, maxBytes = 10000) {
          "Phoenix birds sleep at my fireplace")
  def skipRateLimitsForThisSite: Action[JsValue] =
        PostJsonAction(RateLimits.BrowserError, maxBytes = 150) { request =>
  def addAdminNotice: Action[JsValue] =
        PostJsonAction(RateLimits.BrowserError, maxBytes = 50) { request =>
import scala.util.Try
  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = MaxPostSize) {
        RateLimits.DraftSomething, maxBytes = MaxPostSize) { request =>
  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = 1000) {
  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething, maxBytes = 1000) {
        pageId: Option[String], postNr: Option[Int]): Action[Unit] =
      GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
    val drafts = requester flatMap { theRequester =>
  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] =
        GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost, maxBytes = MaxPostSize) {
        AsyncGetActionRateLimited(RateLimits.FetchLinkPreview) { request =>
        GetActionRateLimited(RateLimits.ExpensiveGetRequest) { request =>
  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost, maxBytes = 5000) { request =>
  def movePost: Action[JsValue] = StaffPostJsonAction(maxBytes = 300) { request =>
  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost, maxBytes = 2000) { request =>
  private val ImpersonationCookieName = "esCoImp"
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, userId)
      createSessionIdAndXsrfToken(request.siteId, userId)._3
    val logoutCookie =
      if (anyUserId.isEmpty) Seq(DiscardingSessionCookie)
    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookie: _*)
        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
              // Ignore old impersonation cookies, in case they're leaked somehow.
                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
                  val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, oldUserId)
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, newUser.id)
        security.createSessionIdAndXsrfToken(request.siteId, guestUser.id)
  import context.security.DiscardingSessionCookie
  /** Clears login related cookies and OpenID and OpenAuth stuff, unsubscribes
    * from any event channel.
    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath)
  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St]): Result = {
      request.dao.logout(theRequester, bumpLastSeen = true)
    response.discardingCookies(DiscardingSessionCookie)
    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, member.id)
    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
    import request.body
    CLEAN_UP // remove daoFor, use request.dao instead. Just look in the logs that this'll
    // work fine for sure (shouldn't be any "TyEWEIRDDAO" in the logs).
    val dao = daoFor(request.request)
    if (dao.siteId != request.dao.siteId) {
      if (globals.isProd) {
        logger.warn("Weird: dao.siteId != request.dao.siteId  [TyEWEIRDDAO]")
      }
      else {
        die("TyE305AKTFWJ2", "Wrong dao, *harmmless* but why?")
      }
    }

          val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(dao.siteId, newMember.id)
        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, user.id)
  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply, maxBytes = MaxPostSize) {
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
import talkyard.server.p_Result
  def loadUserAnyDetails(who: String): Action[Unit] = GetAction { request =>
      import request.{dao, theRequester => requester}
        "browserIdDataRecentFirst" -> browserIdDataJson)
  def loadAuthorBlocks(postId: Int): Action[Unit] = GetAction { request =>
  def loadMyPageData(pageIds: St): Action[U] = GetAction { request =>
        maxBytes = 1000) { request =>
        maxBytes = 1000) { request =>
  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
        request =>
  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
        request =>
  def snoozeNotifications(): Action[JsValue] =
          PostJsonAction(RateLimits.ConfigUser, 200) { request =>
  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, maxBytes = 500) {
        request =>
  def listAllUsers(usernamePrefix: String): Action[Unit] = GetAction { request =>
  def listUsernames(pageId: PageId, prefix: String): Action[Unit] = GetAction { request =>
    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookie)
import debiki.dao.{UsersOnlineStuff, NoUsersOnlineStuff}
import ed.server.security.EdSecurity
  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits, maxBytes = 2) {
        request =>
        var forbidden = ForbiddenResult(s"TyM0APPR_-$code", message)
        if (logout) forbidden = forbidden.discardingCookies(security.DiscardingSessionCookie)
        return Future.successful(forbidden)
  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost, maxBytes = 500) {
        request: JsonPostRequest =>
  def loadVoters(postId: PostId, voteType: Int): Action[Unit] = GetAction { request =>
      case _ => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject")
      case _ => throwBadJson("TyE0JSARR", s"$what is not a JsArray")
  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[JsArray] =
      case a: JsArray => a
    readOptLong(json, fieldName).orElse(readOptLong(json, altName)) map { valueAsLong =>
      val maxVal = max getOrElse Int.MaxValue
      val minVal = min getOrElse Int.MinValue
      if (valueAsLong > maxVal)
        throwBadJson("TyEJSNGTMX", s"$fieldName too large: $valueAsLong, max is: $maxVal")
      if (valueAsLong < minVal)
        throwBadJson("TyEJSNLTMN", s"$fieldName too small: $valueAsLong, min is: $minVal")
      valueAsLong.toInt
      requestersJsonImpl(requester, pageRequest.pageId, watchbarWithTitles,
      requestersJsonImpl(requester, anyPageId = None, watchbarWithTitles,
  private def requestersJsonImpl(requester: Participant, anyPageId: Option[PageId],
    val user = readWriteTransaction { tx =>
      user
    }
    logout(user.noDetails, bumpLastSeen = false)
  def logout(pat: Pat, bumpLastSeen: Bo): U = {
    readWriteTransaction { tx =>
    removeUserFromMemCache(pat.id)
    val (user, usersGroupIds) = readWriteTransaction { tx =>
    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true).async(f)
  def AsyncGetActionRateLimited(rateLimits: RateLimits)(f: GetRequest => Future[Result])
    PlainApiAction(cc.parsers.empty, rateLimits).async(f)
    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true)(f)
    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = true, avoidCookies = avoidCookies)(f)
  def GetActionRateLimited(rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = allowAnyone)(f)
  def PostJsonAction(rateLimits: RateLimits, maxBytes: Int,
      rateLimits, allowAnyone = allowAnyone, isLogin = isLogin)(f)
  def PostTextAction(rateLimits: RateLimits, maxBytes: Int, allowAnyone: Boolean = false)(
      rateLimits, allowAnyone = allowAnyone)(f)
  def StaffPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
      cc.parsers.json(maxLength = maxBytes))(f)
  def originOf(request: DebikiRequest[_]) =
  def originOf(request: RequestHeader) =
  def daoFor(request: Request[_]) = {
    val site = globals.lookupSiteOrThrow(originOf(request))
    globals.siteDao(site.id)
  }


  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Option[Participant])
        : (MaySeeOrWhyNot, String) = {
    case SidOk("_api_secret_", 0, _) => true
  def session: mvc.Session = request.session
    siteIdAndCanonicalHostname, sid, xsrfToken, browserId, user, pageExists,
/** Play Framework Actions for requests to Debiki's HTTP API.
  import security.DiscardingSessionCookie
  def PlainApiAction[B](parser: BodyParser[B],
        rateLimits: RateLimits, allowAnyone: Bo = false, isLogin: Bo = false,
        avoidCookies: Bo = false, skipXsrfCheck: Bo = false)
        : ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, rateLimits,
  def PlainApiActionStaffOnly[B](parser: BodyParser[B]): ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, NoRateLimits, staffOnly = true)
    PlainApiActionImpl(parser, rateLimits, viaApiSecretOnly = true)
              SidOk("_api_secret_", 0, Some(SysbotUserId)),
          SidOk("_api_secret_", 0, Some(user.id)), XsrfOk("_api_secret_"), None, block)
      // by Privacy Badger or iOS or browser settings for 3rd-party-cookies.
      // the AvoidCookiesHeaderName header, so we won't froget that we should avoid cookies here.
      val (actualSidStatus, xsrfOk, newCookies) = corsInfo match {
          (SidAbsent, XsrfOk("cors_no_xsrf"), Nil)
        case _: CorsInfo.SameOrigins | CorsInfo.NotCorsNoOriginHeader =>
                request, anyRequestBody = Some(request.body), siteId = site.id,
      val (mendedSidStatus, deleteSidCookie) =
                mendedSidStatus, xsrfOk, anyBrowserId, block)
              .discardingCookies(DiscardingSessionCookie))
        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteSidCookie) {
              .withCookies(newCookies ::: newBrowserIdCookie: _*)
              .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
            if (deleteSidCookie) {
                resultWithCookies.discardingCookies(DiscardingSessionCookie)
          anyUserMaybeSuspended: Option[Participant], sidStatus: SidStatus,
      // in one browser and got logged out there, only.
      if (anyUserMaybeSuspended.exists(_.isDeleted))
          ForbiddenResult("TyEUSRDLD", "That account has been deleted")
            .discardingCookies(DiscardingSessionCookie))
      if (isSuspended && request.method != "GET")
            ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
              .discardingCookies(DiscardingSessionCookie))
        if (isSuspended) None
        else anyUserMaybeSuspended
        site, sidStatus, xsrfOk, browserId, anyUser, dao, request)
        // BUG: (old? can still happen?) We won't get here if e.g. a 403 Forbidden exception
        // was thrown because 'anyUser' was set to None. How solve that?
        result = result.map(_.discardingCookies(DiscardingSessionCookie))(executionContext)
    browserId: Option[BrowserId],
    user: Option[Participant],
    browserId: Option[BrowserId],
    user: Option[Participant],
import p.mvc.{Action, ControllerComponents, RequestHeader, Result}
        request: RequestHeader =>
  private def webSocketImpl(request: RequestHeader): Future[Either[
  private def authenticateWebSocket(site: SiteBrief, request: RequestHeader)
    val (sessionId, xsrfOk, newCookies) =
                request, anyRequestBody = None, siteId = site.id,
    // For now, let's require a browser id cookie — then, *in some cases* simpler
          .discardingCookies(security.DiscardingSessionCookie))
          // + discard browser id co too   *edit:* Why?
          .discardingCookies(security.DiscardingSessionCookie))
    val authnReq = AuthnReqHeaderImpl(site, sessionId, xsrfOk,
 * Copyright (C) 2011-2017 Kaj Magnus Lindberg
import com.debiki.core.isDevOrTest

case class SidExpired(minutesOld: Long, maxAgeMins: Long) extends SidStatus
        siteId: SiteId, expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
        : (SidStatus, XsrfOk, List[Cookie]) = {
    val anySessionIdCookieValue: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
    val anySessionId: Opt[St] =
          anySessionIdCookieValue orElse request.headers.get(SessionIdHeaderName)
    val sessionIdStatus: SidStatus =
          anySessionId.map(
            checkSessionId(siteId, _, now, expireIdleAfterMillis = expireIdleAfterMillis)
            ) getOrElse SidAbsent
    val maybeCredentials = cookies.nonEmpty ||
          // There's also the session id header, SessionIdHeaderName
          // (for embedded discussions, cookies then usually won't work).
          sessionIdStatus != SidAbsent
    val sidXsrfNewCookies: (SidStatus, XsrfOk, List[Cookie]) =

        (sessionIdStatus, xsrfOk, anyNewXsrfCookie)
      else if (isPost && !maybeCredentials) {
        (SidAbsent, XsrfOk("_no_creds_"), Nil)
            checkXsrfToken(
              xsrfToken, anyXsrfCookieValue,
              thereIsASidCookie = anySessionIdCookieValue.isDefined,
              now, expireIdleAfterMillis = expireIdleAfterMillis)
        CLEAN_UP // simplify this weird match-case!  & don't take & "return"
        // sessionIdStatus for no reason all the time.
        val r = sessionIdStatus match {
          case s: SidOk => (s, xsrfOk, Nil)
          case SidAbsent => (SidAbsent, xsrfOk, Nil)
          case s: SidExpired => (s, xsrfOk, Nil)
          case _ =>
                  DiscardingSessionCookie))
        dieIf(isDevOrTest && r != (sessionIdStatus, xsrfOk, Nil), "TyE205RKPG36")
        r
    sidXsrfNewCookies
  def createSessionIdAndXsrfToken(siteId: SiteId, userId: UserId): (SidOk, XsrfOk, List[Cookie]) = {
    COULD_OPTIMIZE // pass settings or a dao to here instead? so won't need to create this 2nd one.
                    // (the caller always already has one)
    val dao = globals.siteDao(siteId)
    // Note that the xsrf token is created using the non-base64 encoded cookie value.
    val sidOk = createSessionId(siteId, userId)
    UX; SECURITY; SHOULD // use HttpOnly cookies — otherwise Safari will delete the cookie
    // after 7 days. See: https://webkit.org/blog/8613/intelligent-tracking-prevention-2-1/
    // the "Client-Side Cookies Capped to 7 Days of Storage", section, and sub section
    // "Will This Change Log Users Out?" — cookies that are Secure and HttpOnly aren't deleted.
    // This means the client can no longer look at the session cookie, to find out if one is
    // logged in? Could add JS variables instead.  [NOCOOKIES]
    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
      maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))
  private val HashLength: Int = 15
  private def secretSalt = globals.applicationSecret
  private def checkSessionId(siteId: SiteId, value: String, now: When, expireIdleAfterMillis: Long)
      : SidStatus = {
    if (value.length <= HashLength) return SidBadFormat
      s"$secretSalt.$siteId$dotUseridDateRandom") take HashLength
    if (hash != realHash) return SidBadHash
    dotUseridDateRandom.drop(1).split('.') match {
            return SidBadFormat
        if (ageMillis > expireIdleAfterMillis)
          return SidExpired(
            minutesOld = ageMillis / MillisPerMinute,
            maxAgeMins = expireIdleAfterMillis / MillisPerMinute)
  private def createSessionId(siteId: SiteId, userId: UserId): SidOk = {
    // For now, create a SID value and *parse* it to get a SidOk.
    // This is stupid and inefficient.
    val uid = "" // for now
      s"$secretSalt.$siteId.$useridDateRandom") take HashLength
    val value = s"$saltedHash.$useridDateRandom"
    checkSessionId(siteId, value, now, expireIdleAfterMillis = Long.MaxValue).asInstanceOf[SidOk]
  def SecureCookie(name: String, value: String, maxAgeSeconds: Option[Int] = None,
        httpOnly: Boolean = false) =
  def DiscardingSessionCookie: DiscardingCookie = DiscardingSecureCookie(SessionIdCookieName)
  //    Set-Cookie: dwCoUserEmail="kajmagnus79%40gmail.com";Path=/
  //    Set-Cookie: dwCoUserName="Kaj%20Magnus";Path=/
  //    Set-Cookie: dwCoUserEmail=kajmagnus79@gmail.com;Path=/
  //    Set-Cookie: dwCoUserName="Kaj Magnus";Path=/
  private def urlEncodeCookie(name: String, value: String, maxAgeSecs: Option[Int] = None) =
      sameSite = anySameSiteCookieValue(),
      httpOnly = false)
      val nestedQueries: Opt[JsArray] = parseOptJsArray(jsVal, "manyQueries")
      val nestedActions: Opt[JsArray] = parseOptJsArray(jsVal, "doActions")
        val (sid, _, sidAndXsrfCookies) = security.createSessionIdAndXsrfToken(siteId, user.id)
            // Not yet weak but later. [weaksid]
          security.createSessionIdAndXsrfToken(siteId, user.id)
      // Not yet weak but later. [weaksid]  [NOCOOKIES]
      "weakSessionId" -> JsString(sid.value)))
// Maybe store this in Myself instead? Next to Myself.mySidPart1ForJs? [sess_in_me]
        isLoggedIn: !!getSetCookie('dwCoSid'),
      const hasSid = getSetCookie('dwCoSid');
    dieIf(!anyMe, 'TyE4032SMH57');
  Server.deleteTempSessId();
export function loadEmailAddressesAndLoginMethods(userId: UserId, success: UserAcctRespHandler) {
  get(`/-/load-email-addrs-login-methods?userId=${userId}`, response => {
    success(response);
  });
export function addEmailAddresses(userId: UserId, emailAddress: string, success: UserAcctRespHandler) {
  postJsonSuccess('/-/add-email-address', success, { userId, emailAddress });
export function suspendUser(userId: UserId, numDays: number, reason: string, success: () => void) {
  postJsonSuccess('/-/suspend-user', success, {
      if (!mainWin.typs.weakSessionId && !getSetCookie('dwCoSid')) {
            `No cookie, no typs.weakSessionId. ` +
  weakSessionId?: St;
  return _.includes(store.siteFeatureFlags, featureFlag) ||
declare function getSetCookie(cookieName: string, value?: string, options?: any): string;
  const currentUser = debiki2.ReactStore.getMe();
  if (currentUser.isLoggedIn) {
      if (currentUser.userId !== parseInt(newUserIdString)) {
// We store a weak session in localStorage, if 3rd party cookies disabled.
// It's fairly ok to use localStorage in our case, see:
//   ../../docs/session-in-local-storage.md
// [NOCOOKIES] [weaksid] ADD_TO_DOCS The session is (will be) "weak", in
// the sense that, even if you're admin, you cannot use it to go to the admin area
// and do things there. Then instead you need to login directly to the Talkyard
// server, rather than on the embedding site via the iframe — so an XSS
// vulnerability on the embedding site (the blog) cannot give admin access.

          theStorage.setItem('talkyardSession', JSON.stringify(item));
  ApiSecret =  // instead of SidOk("_api_secret_", ..)

see: SessionType.AutoTokenSiteCustomSso, and: SidOk("_api_secret_".
Also see: [weaksid].
  const skip2And3Browsers = ['!.2br', '!.3br'];
      it(`Owen remains logged in?  Or needs to login again?`, async () => {
        // Hmm, currently:
  EmbCommentsIframe = 4,
  constructor(aWdioBrowser: WebdriverIOAsync.Browser, name: 'brA' | 'brB' | 'brC' | 'brAll') {
    async executeAsync<T>(script: ((...args: any[]) => T), ...args: any[]): Pr<T> {
            waitForContent?: false, discId?: St, theresOnlyOne?: true } = {}) {
      if (this.#useCommentsIframe?.discussionId) {
    async waitAndClickLast(selector: St): Pr<ClickResult> {
      return await this.waitAndClickNth(selector, -1);
    async waitAndClickNth(selector: string, n: number): Pr<ClickResult> {   // BUG will only scroll the 1st elem into view [05YKTDTH4]
      dieIf(n <= 0, "n starts on 1, change from 0 to 1 please");
      logWarningIf(n !== 1,
      tyAssert.ok(elems.length >= n, `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
      switchToInvites: async () => {
        await this.invitedUsersList.waitUntilLoaded();
        switchToEmailsLogins: async () => {  // RENAME to tabToAccount
        ? 3
        : (onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
            ? 2
            : 1);

Fixed code:
import play.api.libs.json._
  def warnDevDieIf(test: => Bo, errorCode: St, details: St = ""): U =
    warnDbgDieIf(test, errorCode, details)

  RENAME // to ..DevDie..?
  def warnDbgDieIf(test: => Bo, errorCode: St, details: St = ""): U = {
  def warnDevDieUnless(test: => Bo, errorCode: St, details: St = ""): U = {
    if (!test) {
      warnDbgDie(errorCode, details)
    }
  }

    warnDevDie(errorCode, warningMsg)
  }

  def warnDevDie(errorCode: St, warningMsg: St = ""): U = {
  /** Generates a by default 130 bits entropy string, almost 26 chars long since
    * each char in a 32 chars alphabet has 5 bits (but we use 36 chars here).
  def nextRandomString(bitsEntropy: i32 = 130, base36: Bo = true,
          base64UrlSafe: Bo = false): St = {
    require(base36 != base64UrlSafe)
    if (base36) {
      // Hmm the resulting length is a bit random — if the BigInteger happens to start
      // with 0, the base 36 encoding will be shorter (leading zeroes are excluded).
      val bigInt = new java.math.BigInteger(bitsEntropy, _random)
      bigInt.toString(36) // 0..9, a..z
    }
    else {
      assert(base64UrlSafe)
      // Divisible by 8, so byte aligned — _random operates on bytes.
      assert(bitsEntropy % 8 == 0)
      // Divisible by 6, so always result in same length Base64 repr.
      assert(bitsEntropy % 6 == 0)
      val numBytes = bitsEntropy / 8
      val bytesArray = new Array[Byte](numBytes)
      _random.nextBytes(bytesArray)
      val res = acb.Base64.encodeBase64URLSafeString(bytesArray)
      assert(res.length * 6 == bitsEntropy)
      res
    }
  }
  SECURITY; COULD_OPTIMIZE // use BLAKE3 instead.
  private def mdSha1: js.MessageDigest = js.MessageDigest.getInstance("SHA-1") // not thread safe

  /// SHA-512/256 is faster and stronger than SHA-224 (not in a way that matters, but anyway).
  /// And BLAKE3 has a 256 bits output too by default.
  private def mdSha512: js.MessageDigest =
    js.MessageDigest.getInstance("SHA-512") // not thread safe
  def hashSha512FirstHalf32Bytes(text: St): Array[i8] = {
    // I think this isn't the real SHA512/256, but this'll be ok too.
    // SHA-512/256 not incl in Java 8.
    // Later: Use BLAKE3, the Rust implementation, call from Java.
    val res = mdSha512.digest(text.getBytes("UTF-8"))
    assert(res.length == 32 * 2)
    res take 32
  }

  private val AlNumDashRegex = "^[a-zA-Z0-9_-]*$".r
  def anyWeirdJsObjField(obj: JsObject, maxLength: i32,
           allowHeaderNameValues: Bo = false): Opt[St] = {
    unimplIf(allowHeaderNameValues, "TyE50MFEDJ4601")
      if (fieldName.isEmpty)
        return Some("Empty field name")

      if (allowHeaderNameValues) {
        if (!fieldName.isOkHeaderName)
          return Some(s"Weird header name: '$fieldName' [TyE0HDRNAME]")
      }
      else {
        if (!fieldName.isOkVariableName)
          return Some(s"Weird field name: '$fieldName' [TyE0VARNAME]")
      }

      if (fieldName.length > maxLength)
        return Some(s"Too long field name: '$fieldName' [TyE2LNGFLDNM]")

          if (s.value.isEmpty)
            return Some(s"Empty value for field $fieldName")

          if (allowHeaderNameValues) {
            // Don't allow newlines in value?
            unimpl("TyE50MFEDJ4602")
          }
          else if (!s.value.isOkVariableName) {
            return Some(s"Bad value for field $fieldName: '$fieldValue'")
          }

          if (s.value.length > maxLength)
            return Some(s"Too long field value, $fieldName: '$fieldValue'")

          return Some(s"Value of field $fieldName is not a nummer or string; it is a ${
            classNameOf(fieldValue)} [TyEFIELDVALTYP]")
  def jsObjectSize(obj: JsObject, depth: i32 = 0): i32 = {
    TESTS_MISSING
    var size = 0
    for ((fieldName, value) <- obj.fields) {
      size += fieldName.length + jsValueSize(value, depth = depth + 1)
    }
    size
  }

  private def jsValueSize(value: JsValue, depth: i32 = 0): i32 = {
    TESTS_MISSING
    if (depth > 10)
      return Int.MaxValue // for now
    value match {
      case JsNull => 4
      case _: JsBoolean => 4
      case _: JsNumber => 4 // let's just guess 4 bytes. Or log-10?
      case s: JsString => s.value.length
      case _: JsArray =>
        // What about infinitely deeply nested empty arrays in arrays! Would be size 0 :-(
        Int.MaxValue // for now
        // a.value.foldLeft(0)((len, v) => {
        //   jsValueSize(v, sizeThisFar + len, maxAllowedSize, depth = depth + 1)
        // })
      case _: JsObject =>
        Int.MaxValue // for now
        //jsObjectSize(o)
      case _ =>
        // What's this?
        assert(false)
        Int.MaxValue
    }
  }

  // Move to where?
  val JsEmptyObj2: JsObject = JsObject(Nil)

  /*
  def mapKeyValuesTotalLength(map: Map[St, Any], depth: i32 = 0): i32 = {
    var size = 0
    for ((fieldName, value) <- map) {
      size += fieldName.length + jsValueSize(value, depth = depth + 1)
      // or:   + value match { case n: some-number: ...  case s: St => s.length  case _ => no! }
    }
    size
  } */
    def isOkHeaderName: Bo =
      underlying.nonEmpty && AlNumDashRegex.pattern.matcher(underlying).matches

trait SiteTrait {
  def id: SiteId
  def pubId: PubSiteId
  def status: SiteStatus
  def featureFlags: St

  def isTestSite: Bo = id <= Site.MaxTestSiteId

  def isFeatureEnabled(ffName: St, serverFeatureFlags: St): Bo = {
    val offName = "0" + ffName  // zero  — same as when disabling options in Vim
    val enabledWholeServer = serverFeatureFlags.contains(ffName)
    val disabledWholeServer = serverFeatureFlags.contains(offName)
    val enabledThisSite = featureFlags.contains(ffName)
    val disabledThisSite = featureFlags.contains(offName)
    val enabledSomewhere = enabledWholeServer || enabledThisSite
    val disabledSomewhere = disabledWholeServer || disabledThisSite
    // By default a feature flag is not enabled, and can be enabled in a   [ff_on_off]
    // specific site only via this.featureFlags. So, if a feature has been
    // disabled explicitly in the whole server, then, that overrides
    // it being enabled per site (so it'll be disabled everywhere) (Otherwise
    // disabledWholeServer would be pointless.)
    // However, if a feature is enabled by default (for all sites), then,
    // a site can disable it.
    enabledSomewhere && !disabledSomewhere
  }
}


) extends SiteTrait {
  ) extends SiteIdHostnames with SiteLimitsMultipliers with SiteTrait {

  def loadSession(part1Maybe2Or3: Opt[St] = None, part4HttpOnly: Opt[St] = None,
        maybeActiveOnly: Bo = false): Opt[TySessionInDbMaybeBad]
  def loadOneOrTwoSessions(part1Maybe2Or3: Opt[St], part4HttpOnly: Opt[St],
        maybeActiveOnly: Bo): ImmSeq[TySessionInDbMaybeBad]
  def loadActiveSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad]
  def insertValidSession(session: TySession): U
  def upsertSession(session: TySessionInDbMaybeBad): U

  def deletePersonalDataFromOldSessions(): U
package com.debiki.core

import com.debiki.core.Prelude._
import play.api.libs.json.JsObject


/** The session id is split into 5 parts in 3 cookies: a not-HttpOnly cookie,
  * a HttpOnly and a HttpOnly SameSite=Strict cookie.  [cookie_theory]
  *
  * Each one of the 5 parts is, entropy wise, strong enough, on its own.
  * To do more "important" things, more parts are needed. Whilst
  * the first parts, not-HttpOnly, make Talkyard work also in blog comments iframes
  * where cookies tend to not work.
  *
  * If expiredAt or deletedAt is set, the session cannot be used.
  * A session can be both expired and deleted (if it gets deleted just when it expires).
  *
  * @param patId
  * @param createdAt
  * @param deletedAt
  * @param expiredAt — updated lazily, on use. So, even if the
  *   current time is past the createdAt + expiration time, expiredAt might be unset
  *   — but when pat tries to use the session again, the server will notice it has
  *   expired, and update expiredAt, thereby terminating the session.
  * @param version
  * @param startIp — where the user was, when hen started the session.
  * @param startBrowserId
  * @param startHeaders — some relevant headers from the request that created the session.
  * @param part1CompId — comparation id, not really secret.
  * @param part2HashForEmbgStorage
  * @param part3HashForDirJs
  * @param part4HashHttpOnly
  * @param part5HashStrict
  */
case class TySessionInDbMaybeBad(
  patId: PatId,
  createdAt: When,
  deletedAt: Opt[When],
  expiredAt: Opt[When],
  version: i32,
  startIp: Opt[IpAdr],
  startBrowserId: Opt[BrowserIdSt],
  startHeaders: JsObject,
  part1CompId: St,
  part2HashForEmbgStorage: Array[i8],
  part3HashForDirJs: Array[i8],
  part4HashHttpOnly: Array[i8],
  part5HashStrict: Array[i8]) {

  def isValidNow(now: When, expireIdleAfterMins: i32): Bo =
    wasValidJustRecently && !expiresNow(now, expireIdleAfterMins = expireIdleAfterMins)

  def wasValidJustRecently: Bo = !isDeleted && !hasExpired

  def isDeleted: Bo = deletedAt.isDefined
  def hasExpired: Bo = expiredAt.isDefined

  def expiresNow(now: When, expireIdleAfterMins: i32): Bo = {
    val expiresAt = createdAt.millis + expireIdleAfterMins * MillisPerMinute
    expiresAt <= now.millis
  }


  /// We lookup the session by part 1, and part 2 is required — so 1 and 2 are never absent.
  def copyAsValid(part2: St, part3: Opt[St], part4: Opt[St], part5: Opt[St])
          : TySession = {
    dieIf(expiredAt.isDefined || deletedAt.isDefined, "TyESESS0VALID")
    TySession(
          patId = patId,
          createdAt = createdAt,
          version = version,
          startIp = startIp,
          startBrowserId = startBrowserId,
          startHeaders = startHeaders,
          part1CompId = part1CompId,
          part2ForEmbgStorage = part2,
          part2Hash = part2HashForEmbgStorage,
          part3ForDirJs = part3,
          part3Hash = part3HashForDirJs,
          part4HttpOnly = part4,
          part4Hash = part4HashHttpOnly,
          part5Strict = part5,
          part5Hash = part5HashStrict)
  }
}


/** Later, more fields, see:
  * https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-content-or-value
  *
  * @param patId
  * @param createdAt
  * @param version
  * @param part1CompId
  * @param part2ForEmbgStorage
  * @param part2Hash
  * @param part3ForDirJs
  * @param part3Hash
  * @param part4HttpOnly
  * @param part4Hash
  * @param part5Strict
  * @param part5Hash
  * @param isApiCall COULD split TySession into ClientSession and SingleApiReqSession maybe?
  */
case class TySession(
  patId: PatId,
  createdAt: When,
  version: i32,
  startIp: Opt[IpAdr],
  startBrowserId: Opt[BrowserIdSt],
  startHeaders: JsObject,
  part1CompId: St,
  part2ForEmbgStorage: St,
  part2Hash: Array[i8],
  part3ForDirJs: Opt[St],
  part3Hash: Array[i8],
  part4HttpOnly: Opt[St],
  part4Hash: Array[i8],
  part5Strict: Opt[St],
  part5Hash: Array[i8],
  isApiCall: Bo = false) {

  import TySession._
  require(version == 1, "TyE30MFEW25MMR")

  require(jsObjectSize(startHeaders) < MaxSessionHeadersSize, "TyE4MW2AP7J")

  require(part1CompId.length  == SidLengthCharsPart1, s"Len pt 1: ${part1CompId.length}")

  require(part2Hash.length == SidHashLengthBytes, s"Len pt 2 hash: ${part2Hash.length}")
  require(part2ForEmbgStorage.length == SidLengthCharsPart2,
        s"Len pt 2: ${part2ForEmbgStorage.length}")

  require(part3Hash.length == SidHashLengthBytes, s"Len pt 3 hash: ${part3Hash.length}")
  require(part3ForDirJs.forall(_.length == SidLengthCharsPart3),
        s"Len pt 3: ${part3ForDirJs.map(_.length)}")

  require(part4Hash.length == SidHashLengthBytes, s"Len pt 4 hash: ${part4Hash.length}")
  require(part4HttpOnly.forall(_.length == SidLengthCharsPart4),
        s"Len pt 4: ${part4HttpOnly.map(_.length)}")

  require(part5Hash.length == SidHashLengthBytes, s"Len pt 5 hash: ${part5Hash.length}")
  require(part5Strict.forall(_.length == SidLengthCharsPart5),
        s"Len pt 5: ${part5Strict.map(_.length)}")

  if (com.debiki.core.isDevOrTest) {
    import com.debiki.core.Prelude.{hashSha512FirstHalf32Bytes => hash}
    dieIf(!hash(part2ForEmbgStorage).sameElements(part2Hash), "TyEBADHASH02")
    dieIf(part3ForDirJs.exists(!hash(_).sameElements(part3Hash)), "TyEBADHASH03")
    dieIf(part4HttpOnly.exists(!hash(_).sameElements(part4Hash)), "TyEBADHASH04")
    dieIf(part5Strict.exists(!hash(_).sameElements(part5Hash)), "TyEBADHASH05")
  }

  def part1And2: St = part1CompId + part2ForEmbgStorage

  def part3Absent: Bo = part3ForDirJs.isEmpty
  def part4Absent: Bo = part4HttpOnly.isEmpty
  def part4Present: Bo = !part4Absent
  def part5Absent: Bo = part5Strict.isEmpty
}



object TySession {

  val CurVersion = 1

  // A random Base64 char is 6 bits entropy (with a good rand gen).
  val SidEntropyPerChar: i32 = 6
  val SidCharsetBase: i32 = 64

  // These long session ids might seem like a bit overkill — OWASP writes that 64 bits
  // entropy is enough, that's about 11 chars in Base64.  [sid_part1]
  // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-id-entropy
  //
  // However, parts 1, 2 are for embedded comments, and for optionally saving in
  // the localStorage of embedding pages (e.g. blog posts) which is more risky.
  // If indeed parts 1 and 2 get compromised, we can still rely on part 3 alone
  // — it's 144 bits entropy > 64.
  //
  val SidLengthCharsPart1: i32 = 16
  val SidLengthCharsPart2: i32 = 24
  val SidLengthCharsPart3: i32 = 24
  val SidLengthCharsPart4: i32 = 24
  val SidLengthCharsPart5: i32 = 16

  val SidLengthCharsPart12: i32 =
      SidLengthCharsPart1 +
      SidLengthCharsPart2

  val SidLengthCharsPart123: i32 =
      SidLengthCharsPart12 +
      SidLengthCharsPart3

  val SidLengthCharsTotal: i32 =
        SidLengthCharsPart123 +
        SidLengthCharsPart4 +
        SidLengthCharsPart5

  val ApiSecretPart1 = "API_SID_PT_1_pad"          // 16 chars
  val ApiSecretPart2 = "API_SID_PT_2_pad_24_abcd"  // 24 chars
  val ApiSecretPart12: St = ApiSecretPart1 + ApiSecretPart2

  // BLAKE3 and SHA-512/256 have 256 bits output size = 32 bytes.
  val SidHashLengthBytes = 32

  // (Part 3, 4, 5 not included in an API call TySession, so the hashes
  // can be set to whatever. But part 2 is always included; the real hash is needed.)
  val DummyHash: Array[i8] = Array.fill(SidHashLengthBytes)(0.asInstanceOf[i8])
  val DummyHashPart2: Array[i8] = hashSha512FirstHalf32Bytes(ApiSecretPart2)

  val MaxSessionHeadersSize = 1000

  def singleApiCallSession(asPatId: PatId): TySession =
    TySession(
          patId = asPatId,
          // Maybe use now() instead? Shouldn't ever matter.
          createdAt = When.Genesis,
          version = CurVersion,
          startIp = None,
          startBrowserId = None,
          startHeaders = JsObject.empty,
          part1CompId = ApiSecretPart1,
          part2ForEmbgStorage = ApiSecretPart2,
          part2Hash = DummyHashPart2,
          part3ForDirJs = None,
          part3Hash = DummyHash,
          part4HttpOnly = None,
          part4Hash = DummyHash,
          part5Strict = None,
          part5Hash = DummyHash,
          isApiCall = true)

}
  type SidSt = St   // [Scala_3] opaque type
  type BrowserIdSt = St  // [Scala_3] opaque type

  def CHECK_AUTHN_STRENGTH = ()
      delete from sessions_t where site_id_c = ?
  implicit class PimpJsObjWithNullIfEmpty(jsOb: JsObject) {
    def orNullIfEmpty: AnyRef = {
      if (jsOb.value.isEmpty) Null(js.Types.OTHER)
      else jsOb
    }
  }

  def getByteArray(rs: js.ResultSet, column: St): Array[i8] = {
    val bytes = rs.getBytes(column)
    dieIf(bytes eq null, "TyERSNULLBYTES", s"Column $column is null, should be a bytea")
    bytes
  }

  def getOptByteArray(rs: js.ResultSet, column: St): Opt[Array[i8]] = {
    Opt(rs.getBytes(column))
  }

  with SessionsRdbMixin
  def deletePersonalDataFromOldSessions(): U = {
    TESTS_MISSING
    PRIVACY; COULD // make x months below configurable
    /*
    val deleteABitStatement = s"""
          update sessions_t set
            start_ip_c = start_ip_c & inet '255.255.255.0',
            start_headers_c = null
            -- start_browser_id_c = hash(..)
            forgotten_c = 1
          where
            forgotten_c = 0 and                    -- needs new col
            least(deleted_at_c, expired_at_c) < ?  -- needs ix  [sess_deleted_ix]
          """
    runUpdate(deleteABitStatement, List(now.minusMonths(SomeMonthsAgo).asTimestamp))

    val deleteMoreStatement = s"""
          update sessions_t set
            start_ip_c = start_ip_c & inet '255.255.0.0',
            start_headers_c = null,
            start_browser_id_c = null,
            forgotten_c = 2
          where
            forgotten_c = 1 and                    -- needs new col
            least(deleted_at_c, expired_at_c) < ?  -- needs ix
          """
    runUpdate(deleteMoreStatement, List(now.minusMonths(SomeYearsAgo).asTimestamp))
    */
  }



      delete from sessions_t
/**
 * Copyright (c) 2021 Kaj Magnus Lindberg
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package com.debiki.dao.rdb

import com.debiki.core._
import com.debiki.core.Prelude._
import Rdb._
import RdbUtil._
import java.sql.{ResultSet => j_ResultSet, SQLException => j_SQLException}
import collection.{mutable => mut}



/** Manages user sessions. But why are sessions stored in Postgres, not Redis?
  * For security reasons. It's good to be able to:
  * - Look up all current sessions by a certain user.
  * - Look up all sessions by ip addr.
  * - List sessions by time and user / ip.
  * - Remember if a session's ip address changes (could be suspicious — is there
  *   enough time for the person to travel to the new location?).
  * - Delete some or all of a user's session, and remember who did this and when.
  * - Delete all posts made by a session — in case someone got pwned.
  *
  * All that would be doable in Redis, but it's simpler, in Postgres. And,
  * combined with an app server in-process mem cache, this approach can also be made
  * faster than Redis (which is an out of process mem cache, slower).
  *
  * Maybe some time later, sessions will need to be temporarily buffered in Redis
  * or elsewhere somehow, and only written to Postgres every once in a while
  * (except for when logging out — that'd get persisted immediately).
  * Or even some other type of storage.  But that's in the distant future.
  */
trait SessionsRdbMixin extends SiteTransaction {
  self: RdbSiteTransaction =>


    /// This loads also sessions that have expired, but whose expired_at_c
    /// column hasn't been updated yet. — That's why there's "Maybe" in the
    /// val name — sessions that are *maybe* still active.
    private val AndMaybeActiveOnlySql =
      "and deleted_at_c is null and expired_at_c is null"

  def loadSession(part1Maybe2Or3: Opt[St] = None, part4HttpOnly: Opt[St] = None,
        maybeActiveOnly: Bo = false)
        : Opt[TySessionInDbMaybeBad] = {
    dieIf(part1Maybe2Or3.isDefined == part4HttpOnly.isDefined, "TyE50MG24SMP")
    val part1OrHash4 = part4HttpOnly.map(hashSha512FirstHalf32Bytes) getOrElse {
      // Compare with part 1 only (not 2 or 3).
      part1Maybe2Or3.getOrDie("TyE603MWEG657") take TySession.SidLengthCharsPart1
    }

    val andMaybeActiveOnlySql =
          if (maybeActiveOnly) AndMaybeActiveOnlySql else ""

    // Test, part 4: sso-test  TyT4ABKRW0268.TyTESESS123GONE
    // Test, part 1: All the time, whenever logged in.

    val colName = if (part1Maybe2Or3.isDefined) "part_1_comp_id_c" else "hash_4_http_only_c"
    val query = s"""
          select * from sessions_t
          where site_id_c = ?
            and $colName = ?
            $andMaybeActiveOnlySql """
    runQueryFindOneOrNone(query, List(siteId.asAnyRef, part1OrHash4), parseSession)
  }


  def loadOneOrTwoSessions(part1Maybe2Or3: Opt[St], part4HttpOnly: Opt[St],
        maybeActiveOnly: Bo = false): ImmSeq[TySessionInDbMaybeBad] = {
    if (part1Maybe2Or3.isEmpty && part4HttpOnly.isEmpty)
      return Vec.empty

    val values = MutArrBuf[AnyRef]()
    values.append(siteId.asAnyRef)

    val partOneEq = part1Maybe2Or3 map { part1Etc =>
      // Skip part 2 and 3.
      values.append(part1Etc take TySession.SidLengthCharsPart1)
      "part_1_comp_id_c = ?"
    } getOrElse ""

    val or =
          if (part1Maybe2Or3.isEmpty || part4HttpOnly.isEmpty) ""
          else "or"

    val partFourEq = part4HttpOnly map { part4 =>
      values.append(hashSha512FirstHalf32Bytes(part4))
      "hash_4_http_only_c = ?"
    } getOrElse ""

    val andMaybeActiveOnlySql =
          if (maybeActiveOnly) AndMaybeActiveOnlySql else ""

    val query = s"""
          select * from sessions_t
          where site_id_c = ? and ($partOneEq $or $partFourEq) $andMaybeActiveOnlySql"""

    runQueryFindMany(query, values.toList, parseSession)
  }


  def loadActiveSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad] = {
    val query = s"""
          -- ix: sessions_i_patid_createdat_active
          select * from sessions_t
          where site_id_c = ?
            and pat_id_c = ?
            and deleted_at_c is null
            and expired_at_c is null
          order by created_at_c desc  """
    runQueryFindMany(query, List(siteId.asAnyRef, patId.asAnyRef), parseSession)
  }


  def insertValidSession(session: TySession): U = {
    dieIf(session.part1CompId == TySession.ApiSecretPart1, "TyE7P02MRED1")
    dieIf(session.part2Hash sameElements TySession.DummyHashPart2, "TyE7P02MRED2")

    val statement = s"""
          insert into sessions_t (
              site_id_c,
              pat_id_c,
              created_at_c,
              deleted_at_c,
              expired_at_c,
              version_c,
              start_ip_c,
              start_headers_c,
              start_browser_id_c,
              part_1_comp_id_c,
              hash_2_for_embg_storage_c,
              hash_3_for_dir_js_c,
              hash_4_http_only_c,
              hash_5_strict_c)
          values (?, ?, ?, null, null, ?, ?::inet, ?, ?, ?, ?, ?, ?, ?)  """

    val values = List(
          siteId.asAnyRef,
          session.patId.asAnyRef,
          session.createdAt.asTimestamp,
          session.version.asAnyRef,
          session.startIp.orNullVarchar,
          session.startHeaders.orNullIfEmpty,
          session.startBrowserId.orNullVarchar,
          session.part1CompId,
          session.part2Hash,
          session.part3Hash,
          session.part4Hash,
          session.part5Hash)

    runUpdateSingleRow(statement, values)
  }


  def upsertSession(session: TySessionInDbMaybeBad): U = {
    dieIf(session.part1CompId == TySession.ApiSecretPart1, "TyE7P02MRED3")
    dieIf(session.part2HashForEmbgStorage sameElements TySession.DummyHashPart2, "TyE7P02MRED4")

    // Inserting expired sessions can be useful if restoring from a backup.
    val statement = """
          insert into sessions_t (
              site_id_c,
              pat_id_c,
              created_at_c,
              deleted_at_c,
              expired_at_c,
              version_c,
              start_ip_c,
              start_headers_c,
              start_browser_id_c,
              part_1_comp_id_c,
              hash_2_for_embg_storage_c,
              hash_3_for_dir_js_c,
              hash_4_http_only_c,
              hash_5_strict_c)
          values (?, ?, ?, ?, ?, ?, ?::inet, ?, ?, ?, ?, ?, ?, ?)
          on conflict (site_id_c, pat_id_c, created_at_c)   -- pk
          do update set
              deleted_at_c = least(sessions_t.deleted_at_c, excluded.deleted_at_c),
              expired_at_c = least(sessions_t.expired_at_c, excluded.expired_at_c)  """

    val values = List(
          siteId.asAnyRef,
          session.patId.asAnyRef,
          session.createdAt.asTimestamp,
          session.deletedAt.orNullTimestamp,
          session.expiredAt.orNullTimestamp,
          session.version.asAnyRef,
          session.startIp.orNullVarchar,
          session.startHeaders.orNullIfEmpty,
          session.startBrowserId.noneIfBlank.orNullVarchar,
          session.part1CompId,
          session.part2HashForEmbgStorage,
          session.part3HashForDirJs,
          session.part4HashHttpOnly,
          session.part5HashStrict)

    runUpdateSingleRow(statement, values)
  }


  private def parseSession(rs: j_ResultSet): TySessionInDbMaybeBad = {
    TySessionInDbMaybeBad(
          patId =  getInt32(rs, "pat_id_c"),
          createdAt = getWhen(rs, "created_at_c"),
          deletedAt = getOptWhen(rs, "deleted_at_c"),
          expiredAt = getOptWhen(rs, "expired_at_c"),
          version = getInt(rs, "version_c"),
          startIp = getOptString(rs, "start_ip_c"),
          startHeaders = getOptJsObject(rs, "start_headers_c") getOrElse JsEmptyObj2,
          startBrowserId = getOptString(rs, "start_browser_id_c"),
          part1CompId = getString(rs, "part_1_comp_id_c"),
          part2HashForEmbgStorage = getByteArray(rs, "hash_2_for_embg_storage_c"),
          part3HashForDirJs = getByteArray(rs, "hash_3_for_dir_js_c"),
          part4HashHttpOnly = getByteArray(rs, "hash_4_http_only_c"),
          part5HashStrict = getByteArray(rs, "hash_5_strict_c"))
  }

}
import talkyard.server.authn.MinAuthnStrength
  def logBrowserErrors: Action[JsValue] = PostJsonAction(
        RateLimits.BrowserError, MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 10000) {
          "Phoenix birds sleep at my fireplace, and more than one")
  def skipRateLimitsForThisSite: Action[JsValue] = PostJsonAction(
        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 150) {
            request =>
  def addAdminNotice: Action[JsValue] = PostJsonAction(
        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 50) { request =>
import talkyard.server.authn.MinAuthnStrength

  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
        RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { request =>
    CHECK_AUTHN_STRENGTH

  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {
  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {

    CHECK_AUTHN_STRENGTH
import talkyard.server.authn.MinAuthnStrength
        pageId: Option[String], postNr: Option[Int],
        ): Action[Unit] =
            GetActionRateLimited(
              RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

    val drafts: ImmSeq[Draft] = requester flatMap { theRequester =>
  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] = GetActionRateLimited(
        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
    CHECK_AUTHN_STRENGTH

        AsyncGetActionRateLimited(
            RateLimits.FetchLinkPreview, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

        GetActionRateLimited(RateLimits.ExpensiveGetRequest,
            MinAuthnStrength.EmbeddingStorageSid12) { request =>

    CHECK_AUTHN_STRENGTH

  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 5000) { request =>
    CHECK_AUTHN_STRENGTH

  def movePost: Action[JsValue] = StaffPostJsonAction(
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 300) { request =>
    CHECK_AUTHN_STRENGTH

  /** If in iframe, either no cookies get included, or all — so won't
    * run into problems with just parts of the session id being present,
    * and we can leave the authn strength at the default, MinAuthnStrength.Normal.
    *
    * Might need to ask the user to click a button in the iframe, triggering
    * iOS to show a dialog where the user can let the iframe use cookies.  [ios_itp]
    */
        CHECK_AUTHN_STRENGTH // done just above. Some time later, maybe also
        // some per category checks?
        // Apparently Safari can pop up a dialog where the user can let the iframe
        // use cookies, if hen interacts with the iframe. So, if a blog comments
        // discussion requires authn to read, Ty could show a button in the iframe,
        // like, "Click to authenticate"?
        // Then, if clicking, Safari would ask if the iframe was allowed to use cookies,
        // and (if answering Yes), one would get logged in directly, if there were
        // cookies already?  [ios_itp]

          request.anyTySession,
import talkyard.server.authn.MinAuthnStrength
  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost,
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2000) { request =>
    CHECK_AUTHN_STRENGTH

  private val ImpersonationCookieName = "esCoImp"  ; SECURITY ; SHOULD // add a HttpOnly cookie too? & delete. Both cookies required.
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, userId)
      createSessionIdAndXsrfToken(request, userId)._3
    val logoutCookies =
      if (anyUserId.isEmpty) DiscardingSessionCookies
    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookies: _*)
        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
              wasImpersonating = true)
                // Ignore old impersonation cookies, in case they're leaked somehow.
                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
                        wasImpersonating = true)
                  val (_, _, sidAndXsrfCookies) =
                        createSessionIdAndXsrfToken(request, oldUserId)
    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, newUser.id)
            security.createSessionIdAndXsrfToken(request, guestUser.id)
  import context.security.DiscardingSessionCookies
  /** Clears session cookies and ends the session server side too; unsubscribes
    * from any websockets channel.
    SECURITY // optionally log out from all devices?
    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath,
          wasImpersonating = false)
  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St],
          wasImpersonating: Bo): Result = {
    AUDIT_LOG // session id destruction

      request.dao.logout(theRequester, bumpLastSeen = !wasImpersonating)
    dao.terminateSessionForCurReq(request.underlying)
    response.discardingCookies(DiscardingSessionCookies: _*)
  // Discard these also if logging in with username + password?  [clear_aun_cookies]
    // [.clearing_cookies] [clear_aun_cookies]
    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, member.id)
    // This not needed? These cookies, and many more, already cleared at the
    // end of the caller, tryLoginOrShowCreateUserDialog()  [.clearing_cookies].
    val (sid, _, sidAndXsrfCookies) =
          createSessionIdAndXsrfToken(request, loginGrant.user.id)
    import request.{body, dao}
          val (sid, _, sidAndXsrfCookies) =
                createSessionIdAndXsrfToken(request, newMember.id)
      COULD_OPTIMIZE // Clear login related cookies? [clear_aun_cookies]
      // Done already in the OAuth login controller; it clears these: CookiesToDiscardAfterLogin.

        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, user.id)
import talkyard.server.authn.MinAuthnStrength
  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
    CHECK_AUTHN_STRENGTH

    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, loginGrant.user.id)
import talkyard.server.authn.MinAuthnStrength
  def loadUserAnyDetails(who: St): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    // Maybe incl less data if embedded?
    CHECK_AUTHN_STRENGTH

    import request.{dao, theRequester => requester}
      // To do: Incl old expired sessions too? Or maybe that should be a separate
      // endpoint. [gdpr]
      val sessions = dao.listPatsSessions(userId)

        "browserIdDataRecentFirst" -> browserIdDataJson,
        "sessions" -> sessions.map(s => JsSession(s, inclPart1 = false)))
  def loadAuthorBlocks(postId: Int): Action[Unit] = GetActionRateLimited(
        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>

    CHECK_AUTHN_STRENGTH

  def loadMyPageData(pageIds: St): Action[U] = GetAction2(RateLimits.ReadsFromDb,
        MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

      anyTySession = request.anyTySession,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
        CHECK_AUTHN_STRENGTH // Skip the notifications, if in embedded iframe?

        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
    CHECK_AUTHN_STRENGTH // + may see?

  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
        maxBytes = 200) { request =>
  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
        maxBytes = 200) { request =>
  def snoozeNotifications(): Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
        maxBytes = 200) { request =>
  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) { request =>
    CHECK_AUTHN_STRENGTH

  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
  def listAllUsers(usernamePrefix: St): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
  def listUsernames(pageId: PageId, prefix: St): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH  // disallow if just sid part 1+2 but not embedded page

    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookies: _*)
import debiki.dao.NoUsersOnlineStuff
import talkyard.server.authn.MinAuthnStrength
  CHECK_AUTHN_STRENGTH
    // & sid leveL?
  CHECK_AUTHN_STRENGTH
      request.anyTySession,
  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits,
        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2) { request =>
    CHECK_AUTHN_STRENGTH
        var forbiddenResp = ForbiddenResult(s"TyM0APPR_-$code", message)
        if (logout) {
          COULD_OPTIMIZE // do these two in the same tx:
          dao.logout(request.theReqer, bumpLastSeen = true)
          dao.terminateSessionForCurReq(request.underlying)

          forbiddenResp = forbiddenResp.discardingCookies(security.DiscardingSessionCookies: _*)
        }
        return Future.successful(forbiddenResp)
      request.anyTySession,
      request.anyTySession,
import talkyard.server.authn.MinAuthnStrength

  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost,
          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) {
          request: JsonPostRequest =>
    CHECK_AUTHN_STRENGTH

  def loadVoters(postId: PostId, voteType: Int): Action[U] = GetActionRateLimited(
          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
    CHECK_AUTHN_STRENGTH

  def parseJson(jsonSt: St): JsValue = {
    // Play uses JacksonJson.parseJsValue, see: play.api.libs.json.
    // which throws: IOException, JsonParseException, JsonMappingException,
    // and the latter are subclasses of IOException.
    try Json.parse(jsonSt)
    catch {
      case ex: java.io.IOException =>
        throwBadJson("TyEPARSJSN", s"Cannot parse text as json: ${ex.getMessage}")
    }
  }

      case x => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject, it is a: ${classNameOf(x)}")
      case x => throwBadJson("TyE0JSARR", s"$what is not a JsArray, it is a: ${classNameOf(x)}")
  def asInt64(json: JsValue, what: St): i64 =
    json match {
      case n: JsNumber =>
        try n.value.toLongExact
        catch {
          case _: java.lang.ArithmeticException =>
            throwBadJson("TyE0JSINT64", s"$what does not fit in a 64 bit integer")
        }
      case x =>
        throwBadJson("TyE0JSNUM", s"$what is not a JsNumber, it is a: ${classNameOf(x)}")
    }

  def asInt32(json: JsValue, what: St, min: Opt[i32] = None,
          max: Opt[i32] = None): i32 = {
    val int64 = asInt64(json, what)
    int64To32ThrowIfOutOfRange(int64, what, min = min, max = max)
  }

  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[IndexedSeq[JsValue]] =
      case a: JsArray => a.value
    val firstFieldValue = readOptLong(json, fieldName)
    firstFieldValue.orElse(readOptLong(json, altName)) map { valueAsLong =>
      val usedName = if (firstFieldValue.isDefined) fieldName else altName
      int64To32ThrowIfOutOfRange(valueAsLong, usedName, min = min, max = max)
  private def int64To32ThrowIfOutOfRange(valueAsLong: i64, name: St, min: Opt[i32] = None,
        max: Opt[i32] = None): i32 = {
    val maxVal = max getOrElse Int.MaxValue
    val minVal = min getOrElse Int.MinValue
    if (valueAsLong > maxVal)
      throwBadJson("TyEJSNGTMX", s"$name too large: $valueAsLong, max is: $maxVal")
    if (valueAsLong < minVal)
      throwBadJson("TyEJSNLTMN", s"$name too small: $valueAsLong, min is: $minVal")
    valueAsLong.toInt
  }
    parseInt64(json, fieldName)

  def parseInt64(json: JsValue, fieldName: St): i64 =
import ed.server.security.{SidStatus, SidOk}
      requestersJsonImpl(pageRequest.sid, requester, pageRequest.pageId, watchbarWithTitles,
      requestersJsonImpl(request.sid, requester, anyPageId = None, watchbarWithTitles,
  private def requestersJsonImpl(
        sid: SidStatus, requester: Participant, anyPageId: Option[PageId],
    // Parts 2 and other parts are all long enough themselves,  so we can include
    // part 1 in generated html pages. [sid_part1]
    val sidPart1: JsValue = sid match {
      case s: SidOk => JsString(s.part1CompId)
      case _ => JsNull
    }

      "mySidPart1" -> sidPart1,
  with talkyard.server.sess.SessionSiteDaoMixin
    readTxTryReuse(anyTx)(fn)

  def readTxTryReuse[R](anyTx: Opt[SiteTx])(fn: SiteTx => R): R =
  def deletePersonalDataFromOldSessions(): U = {
    writeTxLockAllSites { tx =>
      tx.deletePersonalDataFromOldSessions()
    }
  }

    writeTx { (tx, staleStuff) =>

      staleStuff.addPatIds(Set(userId))
      logout(user.noDetails, bumpLastSeen = false, anyTx = Some(tx, staleStuff))
      terminateSessions(  // [end_sess]
            forPatId = user.id, all = true, anyTx = Some(tx, staleStuff))
    }
  def logout(pat: Pat, bumpLastSeen: Bo, anyTx: Opt[(SiteTx, StaleStuff)] = None): U = {
    if (bumpLastSeen) writeTxTryReuse(anyTx) { (tx, staleStuff) =>
      staleStuff.addPatIds(Set(pat.id))

    UX; COULD // Maybe a WebSocket channel should remember which session & browser it
    // got started from? Rather than only what user. So can disconnect the right browser,
    // instead of all the user's browsers. For example, if impersonating,
    // then, this'll disconnect all current channels of the real person,
    // and mark hen as away (maybe incorrectly).
    // [which_ws_session]
    val (user, usersGroupIds) = writeTx { (tx, staleStuff) =>
      // logout(..) done below.  [end_sess]
      terminateSessions(forPatId = userId, all = true, anyTx = Some(tx, staleStuff))


    // Or just call:  logout(user, bumpLastSeen = false)
    pubSub.unsubscribeUser(siteId, user.briefUser)
    // terminateSessions() done above.
    new talkyard.server.sess.SessionController(cc, context),
import talkyard.server.authn.MinAuthnStrength
    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
          allowAnyone = true).async(f)
  def AsyncGetActionRateLimited(rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        )(f: GetRequest => Future[Result])
    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength).async(f)
  @deprecated("Use GetActionRateLimited instead, remove this, and rename it to this", "")
  def GetAction2(  // use GetActionRateLimited instead!
        rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        )(f: GetRequest => Result): Action[Unit] =
    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength)(f)

  SECURITY; COULD // let MinAuthnStrength be a param, so it's more explicitly visible
  // in the endpoint fn declaration, here and everywhere — don't let EmbeddedHalfSidOld
  // be the default anywhere?
    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
          allowAnyone = true)(f)
    PlainApiAction(cc.parsers.empty, rateLimits, MinAuthnStrength.EmbeddingStorageSid12,
          allowAnyone = true, avoidCookies = avoidCookies)(f)
  def GetActionRateLimited(
        rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
  def PostJsonAction(rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        maxBytes: Int,
        rateLimits, minAuthnStrength, allowAnyone = allowAnyone, isLogin = isLogin)(f)
  def PostTextAction(rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        maxBytes: Int, allowAnyone: Boolean = false)(
      rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
  def StaffPostJsonAction(
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
      cc.parsers.json(maxLength = maxBytes), minAuthnStrength)(f)
  def originOf(request: DebikiRequest[_]): St =
  def originOf(request: RequestHeader): St =
    *
    * Also needs any authn strength? Maybe: WhoInfo = ReqerInfo = Opt[(Pat, Opt[AuthnStrength])]?
    * Maybe Opt[(Pat, AuthnStrength)] where AuthnStrength can be [InternalJob], instead of None?
  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Opt[Pat])
        : (MaySeeOrWhyNot, St) = {

  def anyTySession: Opt[TySession]
  def tySession: TySession = anyTySession getOrElse throwForbidden("TyE0SESS", "Not logged in")

    case SidOk(TySession.ApiSecretPart12, 0, _) => true
  val anyTySession: Opt[TySession],
  anyTySession: Opt[TySession],
    siteIdAndCanonicalHostname, anyTySession, sid, xsrfToken, browserId, user, pageExists,
import talkyard.server.authn.MinAuthnStrength
/** Play Framework Actions for requests to Talkyard's HTTP API.
  import security.DiscardingSessionCookies
  def PlainApiAction[B](
        parser: BodyParser[B],
        rateLimits: RateLimits,
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
        allowAnyone: Bo = false,
        isLogin: Bo = false,
        avoidCookies: Bo = false,
        skipXsrfCheck: Bo = false,
        ): ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, rateLimits, minAuthnStrength = minAuthnStrength,
  def PlainApiActionStaffOnly[B](
          parser: BodyParser[B],
          minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
          ): ActionBuilder[ApiRequest, B] =
    PlainApiActionImpl(parser, NoRateLimits, minAuthnStrength, staffOnly = true)
    PlainApiActionImpl(parser, rateLimits, MinAuthnStrength.ApiSecret, viaApiSecretOnly = true)
        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
              Some(TySession.singleApiCallSession(asPatId = SysbotUserId)),
              SidOk(TySession.ApiSecretPart12, 0, Some(SysbotUserId)),
          Some(TySession.singleApiCallSession(asPatId = user.id)),
          // ... getting fixed, see TySession just above, [btr_sid].
          SidOk(TySession.ApiSecretPart12, 0, Some(user.id)),
          XsrfOk(TySession.ApiSecretPart12), None, block)
      // by iOS ITP or FF ETP or browser settings for 3rd-party-cookies.
      // the AvoidCookiesHeaderName header, so we won't forget that we should avoid cookies here.
      val CheckSidAndXsrfResult(
              anyTySession, actualSidStatus, xsrfOk, newCookies, delFancySidCookies) =
            corsInfo match {
          CheckSidAndXsrfResult(
                None, SidAbsent, XsrfOk("cors_no_xsrf"), Nil, Nil)
        case ci =>
          dieIf(ci.isCrossOrigin, "TyEJ2503TKHJ")
                request, anyRequestBody = Some(request.body), site, dao,
      val (mendedSidStatus, deleteAllSidCookies) =
                anyTySession, mendedSidStatus, xsrfOk, anyBrowserId, block)
          // CLEAN_UP have getUserBySessionId() return an error instead of throwing
            // (Does it make sense to delete any related session from sessions_t?)
              .discardingCookies(DiscardingSessionCookies: _*))
        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteAllSidCookies
              && delFancySidCookies.isEmpty) {
                  .withCookies(newCookies ::: newBrowserIdCookie: _*)
                  .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
            if (deleteAllSidCookies) {
              resultWithCookies =
                    resultWithCookies.discardingCookies(DiscardingSessionCookies: _*)
            }
            else if (delFancySidCookies.nonEmpty) {
                    resultWithCookies.discardingCookies(delFancySidCookies: _*)
          anyUserMaybeSuspended: Option[Participant],
          anyTySession: Opt[TySession], sidStatus: SidStatus,
      // in one browser and got logged out, when this request was going on already?
      if (anyUserMaybeSuspended.exists(_.isDeleted)) {
        // A race. Any session already deleted by UserDao [end_sess],
        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID01")
              ForbiddenResult("TyEUSRDLD", "That account has been deleted")
                  .discardingCookies(DiscardingSessionCookies: _*))
      }
      if (isSuspended && request.method != "GET") {
        // A race. Any session already deleted by UserDao [end_sess],
        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID02")
              ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
                  .discardingCookies(DiscardingSessionCookies: _*))
      }
            if (!isSuspended) anyUserMaybeSuspended
            else {
              // If suspended, one can still see publicly visible things.
              None
            }
      // Some staffOnly endpoints are okay with an embedded session — namely
      // if moderating embedded comments [mod_emb_coms_sid]. But admin endpoints always
      // need the full session id (not just parts 1+2+3, but also part 4 HttpOnly).
      dieIf((adminOnly || superAdminOnly) && !minAuthnStrength.fullSidRequired, "TyE502MGE6M1")

      if (anyTySession.exists(_.isApiCall)) {
        // Fine, we've checked an API secret, no session parts needed.
        assert(anyTySession.get.part1CompId == TySession.ApiSecretPart1, "TyE502MEG5")
      }
      else if (anyTySession.forall(_.part4Present)) {
        // Then either 1) the user isn't logged in; there is no session — that's fine;
        // we'll do authZ checks later; some endpoints (e.g. for logging in) are accessible
        // also if not logged in.
        // Or 2) the user is logged in, and this request does include the HttpOnly
        // session part — all fine.
      }
      else if (minAuthnStrength.fullSidRequired || staffOnly || adminOnly || superAdminOnly) {
        // Part 4 HttpOnly is required, but is missing.  Dupl code [btr_sid_part_4]
        assert(anyTySession.isDefined, "TyE04MWG245")
        assert(anyTySession.get.part4Absent, "TyE04MWG246")
        val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
        val onlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
        val doUseFancySid = onlyFancySid || tryFancySid
        if (doUseFancySid) {
          throwForbidden("TyEWEAKSID_",
                s"Please log out and log in, to get a complete session id — \n" +
                s"this endpoint, ${request.path}, requires the HttpOnly part of the session id")
        }
      }
      else {
        // Part 4 of the session is missing, and the current endpoint doesn't need part 4
        // (such endpoints are for embedded discussions — then, cookies don't work (they
        // generally don't work in iframes), so we're getting only session parts 1+2(+3) via
        // javascript and custom HTTP headers).
        dieUnless(anyTySession.exists(_.part4Absent), "TyE70MWEG25SM")
      }

        site, anyTySession, sidStatus, xsrfOk, browserId, anyUser, dao, request)
        // Harmless "problem": This code won't run, if we started handling the request,
        // but then an exception got thrown (e.g. a 404 Access Denied ResultException,
        // see above).  Then, the cookies won't get deleted — which doesn't really matter,
        // since they're unusable; session deleted server side already.  [btr_sid]
        result = result.map(_.discardingCookies(DiscardingSessionCookies: _*))(executionContext)
    anyTySession: Opt[TySession],
    browserId: Opt[BrowserId],
    user: Opt[Pat],
    anyTySession: Opt[TySession],
    browserId: Opt[BrowserId],
    user: Opt[Pat],
    dao.deletePersonalDataFromOldSessions()
import ed.server.security.CheckSidAndXsrfResult
import p.mvc.{Action, ControllerComponents, RequestHeader => play_RequestHeader, Result}
        request: play_RequestHeader =>
  private def webSocketImpl(request: play_RequestHeader): Future[Either[
  private def authenticateWebSocket(site: SiteBrief, request: play_RequestHeader)
    val CheckSidAndXsrfResult(anyTySession, sessionId, xsrfOk, newCookies, delFancySidCookies) =
                request, anyRequestBody = None, site, dao,
    COULD // delete any delFancySidCookies.

    // For now, let's require part 4 HttpOnly to use WebSocket. And some time later
    // maybe reconsider, and e.g. allow live updates of embedded comments.
    if (anyTySession.exists(_.part4Absent)) {
      // Dupl code [btr_sid_part_4]
      val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
      val onlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
      val doUseFancySid = onlyFancySid || tryFancySid
      if (doUseFancySid) {
        UNTESTED
        throwForbidden("TyEWEAKSIDWS",
              s"Please log out and log in, to get a complete session id — \n" +
              s"WebSockets, ${request.path}, requires the HttpOnly part of the session id")
      }
    }

    // For now, let's require a browser id cookie — then, *in some cases* simpler
          .discardingCookies(security.DiscardingSessionCookies: _*))
          .discardingCookies(security.DiscardingSessionCookies: _*))
    val authnReq = AuthnReqHeaderImpl(site, anyTySession, sessionId, xsrfOk,
 * Copyright (c) 2011-2017, 2021 Kaj Magnus Lindberg
import play.api.libs.json.{JsObject, JsString, JsValue}
import talkyard.server.sess.SessionSiteDaoMixin
import talkyard.server.http
import ed.server.http.AuthnReqHeader
private case class CheckSidResult(
  anyTySession: Opt[TySession],
  sidStatus: SidStatus,
  createCookies: List[Cookie] = Nil,
  discardCookies: List[DiscardingCookie] = Nil)


private object CheckSidResult {
  def noSession(
        sidStatus: SidStatus,
        // discardAllSessionCookies: Bo = false,
        discardCookies: List[DiscardingCookie] = Nil): CheckSidResult = {
    dieIf(sidStatus.canUse && sidStatus != SidAbsent, "TyE2MFKJ063I")
    //dieIf(discardAllSessionCookies && discardCookies.nonEmpty, "TyE2MFKJ0632")
    CheckSidResult(
          anyTySession = None, sidStatus, discardCookies = discardCookies)
          /* Cannot access here:
              if (discardAllSessionCookies) EdSecurity.DiscardingSessionCookies.toList
              else discardCookies) */
  }
}

case class SidExpired(
  minutesOld: i64,
  maxAgeMins: i64,
  wasForPatId: Opt[PatId]) extends SidStatus

case class SidDeleted(
  value: St,
  wasForPatId: Opt[PatId]) extends SidStatus {
}
// CLEAN_UP REFACTOR REMOVE this class? Use only TySession instead.
  // If fancy sid, is part 1 + 2 = 16 + 24 = 40 chars.
  // We should never include session id part 3 — so check that the length is just part 1 + 2
  // or that there's a '.' — then it's an old silly sid.
  dieIf(value.length != TySession.SidLengthCharsPart12 && !value.contains('.'),
        "TyEBADSID12LEN", s"Bad session id parts 1 + 2: '$value'")

  def part1CompId: St = value take TySession.SidLengthCharsPart1

case class CheckSidAndXsrfResult(
  anyTySession: Opt[TySession],
  sidStatus: SidStatus,
  xsrfStatus: XsrfOk,
  cookiesToAdd: List[Cookie],
  cookiesToDelete: List[DiscardingCookie])

  // See TySession [cookie_theory].
  val SessionIdPart123CookieName = "TyCoSid123"
  val SessionIdPart4HttpOnlyCookieName = "TyCoSid4"
  val SessionIdPart5StrictCookieName = "TyCoSid5"

        site: SiteBrief, dao: SessionSiteDaoMixin,
        expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
        : CheckSidAndXsrfResult = {


    // ----- Check session id
    val anySillySidInCookie: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
    val anySidHeaderValue: Opt[St] = request.headers.get(SessionIdHeaderName)
    val (anySillySidInHeader, anyFancySidPart12Maybe3InHeader) = anySidHeaderValue match {
      case None => (None, None)
      case Some(value) =>
        if (value.contains('.')) {
          // It's the old silly sid.
          (Some(value), None)
        }
        else {
          // It's the new fancy sid (Base64, no '.'), not the old silly sid.
          (None, Some(value))
        }
    }

    val anySillySid = anySillySidInCookie orElse anySillySidInHeader

    // New better sid:  [btr_sid]
    val anyFancySidPart123CookieVal = urlDecodeCookie(SessionIdPart123CookieName, request)
    val anyFancySidPart12Maybe3: Opt[St] =
                            anyFancySidPart123CookieVal orElse anyFancySidPart12Maybe3InHeader
    val anyFancySidPart4: Opt[St] = urlDecodeCookie(SessionIdPart4HttpOnlyCookieName, request)
    val anyFancySidPart5: Opt[St] = urlDecodeCookie(SessionIdPart5StrictCookieName, request)
    val checkSidResult: CheckSidResult =
          checkSessionId(
                site,
                anySillySid = anySillySid,
                anyFancySidPart12Maybe3 = anyFancySidPart12Maybe3,
                anyFancySidPart4 = anyFancySidPart4,
                anyFancySidPart5 = anyFancySidPart5,
                dao,
                now,
                expireIdleAfterMillis = expireIdleAfterMillis)

    val sessionIdStatus = checkSidResult.sidStatus
    val upgrToFancySidCookies = checkSidResult.createCookies
    val deleteFancySidCookies = checkSidResult.discardCookies  // rename? deleteFancySidCookies


    // ----- Check xsrf token
    val maybeCredentials = cookies.nonEmpty || anySidHeaderValue.isDefined
    val definitelyNoCreds = !maybeCredentials
    dieIf(definitelyNoCreds && sessionIdStatus != SidAbsent,
          "TyE50RMEG24", s"No creds, still, session id != SidAbsent: $sessionIdStatus")
    val (xsrf, newCookies): (XsrfOk, List[Cookie]) = {
        (xsrfOk, anyNewXsrfCookie)
      else if (isPost && definitelyNoCreds) {
        dieIf(sessionIdStatus != SidAbsent, "TyE502MWEG")
        (XsrfOk(""), Nil)
        val gotAnyCookie =
              anySillySidInCookie.isDefined ||
              anyFancySidPart123CookieVal.isDefined ||
              anyFancySidPart4.isDefined ||
              anyFancySidPart5.isDefined

                checkXsrfToken(
                    xsrfToken, anyXsrfCookieValue,
                    thereIsASidCookie = gotAnyCookie,
                    now, expireIdleAfterMillis = expireIdleAfterMillis)
        sessionIdStatus match {
          case SidBadFormat | SidBadHash =>
                      DiscardingSessionCookies: _*))
          case _ => // fine

        (xsrfOk, Nil)
    }

    dieIf(upgrToFancySidCookies.nonEmpty && deleteFancySidCookies.nonEmpty, "TyE5A6MRE25")

    val allCookiesToAdd =
          if (!maySetCookies) Nil
          else upgrToFancySidCookies:::newCookies
    CheckSidAndXsrfResult(
          checkSidResult.anyTySession,  // [btr_sid]
          sessionIdStatus,  // old, will remove later
          xsrf,
          cookiesToAdd = allCookiesToAdd,
          cookiesToDelete = deleteFancySidCookies)

  def createSessionIdAndXsrfToken(req: AuthnReqHeader, userId: PatId)
        : (SidOk, XsrfOk, List[Cookie]) = {

    AUDIT_LOG // Maybe log session id creation?

    val site = req.site
    val dao = req.dao

    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
    val useFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
          maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))

    // New better sid  [btr_sid]
    // ----------------------------------------

    if (tryFancySid || useFancySid) {
      val (newSidCookies, session) = genAndSaveFancySid(req, patId = userId,
            expireIdleAfterSecs = expireIdleAfterSecs, dao.now(),
            dao.asInstanceOf[SessionSiteDaoMixin])
      val sidOk = SidOk(session.part1And2, ageInMillis = 0, Some(userId))
      return (sidOk, xsrfOk, xsrfCookie :: newSidCookies)
    }

    // Old style sid  (signed cookie)
    // ----------------------------------------

    var sidOk = createSessionId(site, userId)

    COULD_OPTIMIZE // use ArrBuf
  private def genAndSaveFancySid(req: AuthnReqHeader, patId: PatId, expireIdleAfterSecs: i32,
          now: When, dao: SessionSiteDaoMixin): (List[Cookie], TySession) = {

    import com.debiki.core.{TySession => S}
    val totalEntropy = S.SidLengthCharsTotal * S.SidEntropyPerChar
    assert(totalEntropy == 96 + 144 + 144 + 144 + 96)
    val wholeSid = nextRandomString(totalEntropy, base36 = false, base64UrlSafe = true)
    dieIf(wholeSid.length != S.SidLengthCharsTotal, "TyESIDLEN538RMD",
          s"Generated a ${wholeSid.length} chars session id, but should be ${
          S.SidLengthCharsTotal} chars long. Here it is: '$wholeSid' (won't get used)")

    val lenUpTo1 = S.SidLengthCharsPart1
    val lenUpTo2 = lenUpTo1 + S.SidLengthCharsPart2
    val lenUpTo3 = lenUpTo2 + S.SidLengthCharsPart3
    val lenUpTo4 = lenUpTo3 + S.SidLengthCharsPart4

    val part1 = wholeSid.substring(0, lenUpTo1)
    val part2 = wholeSid.substring(lenUpTo1, lenUpTo2)
    val part3 = wholeSid.substring(lenUpTo2, lenUpTo3)
    val part4 = wholeSid.substring(lenUpTo3, lenUpTo4)
    val part5 = wholeSid.substring(lenUpTo4, S.SidLengthCharsTotal)

    assert(part1.length == S.SidLengthCharsPart1)
    assert(part2.length == S.SidLengthCharsPart2)
    assert(part3.length == S.SidLengthCharsPart3)
    assert(part4.length == S.SidLengthCharsPart4)
    assert(part5.length == S.SidLengthCharsPart5)

    val newSidPart123Cookie = urlEncodeCookie(
          SessionIdPart123CookieName, part1 + part2 + part3,
          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = false)

    val newSidPart4Cookie = urlEncodeCookie(
          SessionIdPart4HttpOnlyCookieName, part4,
          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = true)

    val newSidPart5Cookie = urlEncodeCookie(
          SessionIdPart5StrictCookieName, part5,
          maxAgeSecs = Some(expireIdleAfterSecs), httpOnly = true, sameSiteStrict = true)

    val startHeaders = {
      import http.{HeaderNamesLowercase => H}
      // New & nice:
      val chUserAgent = req.headers.get(H.ClientHintUserAgent).trimNoneIfBlank
      val chUaMobile = req.headers.get(H.ClientHintUaMobile).trimNoneIfBlank
      val chUaPlatform = req.headers.get(H.ClientHintUaPlatform).trimNoneIfBlank
      // Old & verbose:
      val userAgent = req.headers.get(H.UserAgent).trimNoneIfBlank

      val mapBuilder = Map.newBuilder[St, JsValue]
      val maxLen = 200 // header max length. 200 is a lot?
      val mobLen = 20  // should be just "?0" or "?1"
      chUserAgent.foreach(v => mapBuilder += H.ClientHintUserAgent -> JsString(v take maxLen))
      chUaMobile.foreach(v => mapBuilder += H.ClientHintUaMobile -> JsString(v take mobLen))
      chUaPlatform.foreach(v => mapBuilder += H.ClientHintUaPlatform -> JsString(v take maxLen))

      // Skip the verbose User-Agent header if we got the new & better client hint header.
      if (chUserAgent.isEmpty) {
        userAgent.foreach(v => mapBuilder += H.UserAgent -> JsString(v take maxLen))
      }
      JsObject(mapBuilder.result)
    }

    val session = TySession(
          patId = patId,
          createdAt = now,
          version = TySession.CurVersion,
          startIp = Some(req.ip),
          startBrowserId = req.browserId.map(_.cookieValue),
          startHeaders = startHeaders,
          part1CompId = part1,
          part2ForEmbgStorage = part2,
          part2Hash = hashSha512FirstHalf32Bytes(part2),
          part3ForDirJs = Some(part3),
          part3Hash = hashSha512FirstHalf32Bytes(part3),
          part4HttpOnly = Some(part4),
          part4Hash = hashSha512FirstHalf32Bytes(part4),
          part5Strict = Some(part5),
          part5Hash = hashSha512FirstHalf32Bytes(part5))

    dao.insertValidSession(session)

    (List(newSidPart123Cookie, newSidPart4Cookie, newSidPart5Cookie), session)
  }
  // 15 chars is 90 bits entropy (15 * 6 bits, using Base64) — that's more than enough:
  // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
  // >  The session ID value must provide at least 64 bits of entropy
  // 11 chars is 66 bits entropy (with a perfect rand num gen).
  private val HashLength: i32 = 15

  private def secretSalt: St = globals.applicationSecret


  private def checkSessionId(
        site: SiteBrief,
        anySillySid: Opt[St],
        anyFancySidPart12Maybe3: Opt[St],
        anyFancySidPart4: Opt[St],
        anyFancySidPart5: Opt[St],
        dao: SessionSiteDaoMixin,
        now: When,
        expireIdleAfterMillis: i64): CheckSidResult = {

    val hasFancySid = anyFancySidPart12Maybe3.isDefined || anyFancySidPart4.isDefined ||
          anyFancySidPart5.isDefined
    val useOnlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)

    if (useOnlyFancySid || (tryFancySid && hasFancySid)) {
      var result = checkFancySessionId(anyPart12Maybe3 = anyFancySidPart12Maybe3,
            anyPart4 = anyFancySidPart4, anyPart5 = anyFancySidPart5,
            dao, now, expireIdleAfterMillis = expireIdleAfterMillis)
      if (anySillySid.isDefined) {
        result = result.copy(
              discardCookies = DiscardingSillySidCookie :: result.discardCookies)
      }
      result
    }
    else {
      var result = checkSillySessionId(site, anySillySid, dao, now, expireIdleAfterMillis)
      if (hasFancySid) {
        result = result.copy(
              discardCookies = DiscardingFancySidCookies ::: result.discardCookies)
      }
      result
    }
  }


  private def checkFancySessionId(  // [btr_sid]
        anyPart12Maybe3: Opt[St], anyPart4: Opt[St], anyPart5: Opt[St],
        dao: SessionSiteDaoMixin, now: When,
        expireIdleAfterMillis: i64): CheckSidResult = {

    val anySession = anyPart12Maybe3 flatMap dao.getSessionByPart1

    val session: TySessionInDbMaybeBad = anySession getOrElse {
      // If pat has logged out client side, when offline, the not-HttpOnly session parts
      // would be gone, whilst we'd still get the 4th and 5th parts in HttpOnly cookies.
      // If so, we should delete the session server side too —
      // so, let's look it up by part 4 — there's an index on part 4 (not part 5), because
      // part 4 should always be present, if part 5 is present (part 4 is SameSite Lax,
      // part 5 Strict).

      // Maybe maybe Safari will sometimes mistakenly auto clear localStorage?
      // See [ios_itp] in maybe-later.txt.

      anyPart4.flatMap(dao.getSessionByPart4HttpOnly(_, maybeActiveOnly = true)) foreach { s =>
        // Test: sso-test  TyT4ABKRW0268.TyTESESS123GONE
        AUDIT_LOG // client side logout
        val sessionDeleted = s.copy(deletedAt = Some(now))
        dao.updateSession(sessionDeleted)
      }

      // Session gone. Delete any cookies.
      val gotCookies = anyPart12Maybe3.isDefined || anyPart4.isDefined || anyPart5.isDefined

      return CheckSidResult.noSession(
            SidAbsent,
            discardCookies = if (gotCookies) DiscardingFancySidCookies else Nil)
    }

    import TySession._

    val thePart12Maybe3: St = anyPart12Maybe3 getOrDie "TyE3MG70QFM2"
    val thePart2: St = thePart12Maybe3.substring(SidLengthCharsPart1, SidLengthCharsPart12)
    val hashPart2: Array[i8] = hashSha512FirstHalf32Bytes(thePart2)
    val part3IsPresent = thePart12Maybe3.length > TySession.SidLengthCharsPart12
    val anyPart3: Opt[St] =
          if (!part3IsPresent) None
          else Some(thePart12Maybe3.substring(SidLengthCharsPart12, SidLengthCharsPart123))

    val anyPart3Hash = anyPart3.map(hashSha512FirstHalf32Bytes)
    val anyPart4Hash = anyPart4.map(hashSha512FirstHalf32Bytes)
    val anyPart5Hash = anyPart5.map(hashSha512FirstHalf32Bytes)

    // Part 2, and 3, 4, 5 if present, must be from the same session.
    // We've checked part 1 already — we found the session via part 1.

    val badPart2 = !hashPart2.sameElements(session.part2HashForEmbgStorage)
    val badPart3 = anyPart3Hash.map(_ sameElements session.part3HashForDirJs) is false
    val badPart4 = anyPart4Hash.map(_ sameElements session.part4HashHttpOnly) is false
    val badPart5 = anyPart5Hash.map(_ sameElements session.part5HashStrict) is false

    if (badPart2 || badPart3 || badPart4 || badPart5) {
      AUDIT_LOG // this is suspicious?

      val sessionDeleted = session.copy(deletedAt = Some(now))
      dao.updateSession(sessionDeleted)

      // Maybe part 4 is from another older/newer session somehow? That'd be weird.
      // Then, invalidate that session too.
      // (Since parts 123 are not-HttpOnly, but parts 4 and 5 are HttpOnly,
      // they could get out of sync maybe because of some unknown bug, or if a script
      // or a person manipulates their cookies.)
      if (badPart4) {
        COULD_OPTIMIZE // We've hashed part 4 above, need not do again in
        // SessionsRdbMixin.loadSession().
        dao.getSessionByPart4HttpOnly(anyPart4.get, maybeActiveOnly = true) foreach {
              differentSession =>
          // The database should prevent any parts being the same (unique indexes,
          // on parts 1 and 4, but not 2, 3 or 5).
          AUDIT_LOG // that we're deleting another session?
          UNTESTED ; TESTS_MISSING  // TyTSESSHALFBAD

          warnDevDieIf(differentSession.part1CompId == session.part1CompId, "TyE603MSEJ56")
          warnDevDieUnless(differentSession.part4HashHttpOnly sameElements
                session.part4HashHttpOnly, "TyE603MSEJ56")

          val differentSessionDeleted = differentSession.copy(deletedAt = Some(now))
          dao.updateSession(differentSessionDeleted)
        }
      }

      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
    }

    if (session.isDeleted || session.hasExpired)
      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)

    // Did the session expire just now?  [lazy_expire_sessions]
    //
    // Maybe instead:
    // val justExpired = session.expiresNow(now, expireIdleAfterMillis / MillisPerMinute)

    UX; SHOULD // bump lastSeenAt somehow, and add idle time to that? [bump_sid_last_use]

    val expiresAt = expireIdleAfterMillis + session.createdAt.millis
    if (expiresAt < now.millis) {
      AUDIT_LOG // session lazy-expired
      // Then don't allow using the session any more, even if the server time gets
      // changed back to before when it expired.
      val sessionExpired = session.copy(expiredAt = Some(now))
      dao.updateSession(sessionExpired)
      // or use SidExpired instead of SidAbsent. But why?
      return CheckSidResult.noSession(SidAbsent, discardCookies = DiscardingSessionCookies)
    }

    val sidOk = SidOk(value = thePart12Maybe3.take(SidLengthCharsPart12),
          ageInMillis = now.millis - session.createdAt.millis,
          userId = Some(session.patId))

    CheckSidResult(
          Some(session.copyAsValid(
              // Parts 3, 4, 5 are optional. (Parts 1 and 2? We found the session via part 1,
              // and part 2 is required, and we have compared it with the session
              // in the database already, above.)
              part2 = thePart2,
              part3 = anyPart3,
              part4 = anyPart4,
              part5 = anyPart5)),
          sidOk)
  }


  private def checkSillySessionId(site: SiteBrief, anyOldSid: Opt[St],
        dao: SessionSiteDaoMixin, now: When,
        expireIdleAfterMillis: i64): CheckSidResult = {

    val value = anyOldSid getOrElse {
      return CheckSidResult.noSession(SidAbsent)
    }

    if (value.length <= HashLength)
      return CheckSidResult.noSession(SidBadFormat)

      s"$secretSalt.${site.id}$dotUseridDateRandom") take HashLength

    if (hash != realHash)
      return CheckSidResult.noSession(SidBadHash)

    val oldOkSid = dotUseridDateRandom.drop(1).split('.') match {
            return CheckSidResult.noSession(SidBadFormat)
        if (ageMillis > expireIdleAfterMillis) {
          val expiredSid = SidExpired(
                  minutesOld = ageMillis / MillisPerMinute,
                  maxAgeMins = expireIdleAfterMillis / MillisPerMinute,
                  wasForPatId = userId)
          return CheckSidResult.noSession(expiredSid)
        }

    var newSidCookies: List[Cookie] = Nil

    // Upgrade old sid to new style sid:  [btr_sid]
    // ----------------------------------------

    /* Maybe skip this. Hard to test?
    if ((tryFancySid || useFancySid) && oldOkSid.userId.isDefined) {
      val dao = anyDao getOrDie "TyE50FREN68"
      val patId = oldOkSid.userId getOrDie "TyE602MTEGPH"
      val settings = dao.getWholeSiteSettings()
      val expireIdleAfterSecs = settings.expireIdleAfterMins * 60
      val (newCookies, sidPart1, sidPart2) =
            genAndSaveFancySid(patId = patId, expireIdleAfterSecs, dao.redisCache,
                isOldUpgraded = true)
      // cookies = newSidPart1Cookie::newSidPart2Cookie::cookies
      result = SidOk(sidPart1,
            expireIdleAfterSecs * 1000, Some(patId))
      newSidCookies = newCookies
    }
    */

    CheckSidResult(anyTySession = None, oldOkSid, createCookies = newSidCookies)
  @deprecated("Now", "Use the fancy session id instead.")
  private def createSessionId(site: SiteBrief, userId: PatId): SidOk = {

      s"$secretSalt.${site.id}.$useridDateRandom") take HashLength
    val value = s"$saltedHash.$useridDateRandom"
    SidOk(value, ageInMillis = 0, Some(userId))
  def SecureCookie(name: St, value: St, maxAgeSeconds: Opt[i32] = None,
        httpOnly: Bo = false): Cookie =
    SECURITY // Remove this? No longer needed — instead, session parts 1-5,
    // and 5 is always strict.
  def DiscardingSessionCookies: List[DiscardingCookie] =
    List(DiscardingSecureCookie(SessionIdCookieName),
        DiscardingSecureCookie(SessionIdPart123CookieName),
        DiscardingSecureCookie(SessionIdPart4HttpOnlyCookieName),
        DiscardingSecureCookie(SessionIdPart5StrictCookieName))

  def DiscardingSillySidCookie: DiscardingCookie =
    DiscardingSecureCookie(SessionIdCookieName)

  def DiscardingFancySidCookies: List[DiscardingCookie] =
    List(DiscardingSecureCookie(SessionIdPart123CookieName),
        DiscardingSecureCookie(SessionIdPart4HttpOnlyCookieName),
        DiscardingSecureCookie(SessionIdPart5StrictCookieName))

  //    Set-Cookie: dwCoUserEmail="someone%40exaple.com";Path=/
  //    Set-Cookie: dwCoUserName="space%20text";Path=/
  //    Set-Cookie: dwCoUserEmail=someone@example.com;Path=/
  //    Set-Cookie: dwCoUserName="space text";Path=/
  private def urlEncodeCookie(name: St, value: St, maxAgeSecs: Opt[i32] = None,
        httpOnly: Bo = false, sameSiteStrict: Bo = false) =
      // Don't set — if set to, say, example.com, then, vulnerabilities at
      // www.example.com might allow an attacker to get access to cookies
      // from secure.example.com
      // https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#domain-and-path-attributes
      sameSite =
            // If debugging on localhost over http, does Strict work?
            if (sameSiteStrict) Some(Cookie.SameSite.Strict)
            else anySameSiteCookieValue(),
      httpOnly = httpOnly)
  /// ts: Session
  def JsSession(sess: TySessionInDbMaybeBad, inclPart1: Bo = true): JsObject = {
    // Don't include the actual session id. (That is, exclude parts 2 – 5. *Could* maybe
    // theoretically include them, since they're hashes, but bad idea, and not needed.)
    var json = Json.obj(
          "patId" -> sess.patId,
          "createdAt" -> JsWhenMs(sess.createdAt),
          "deletedAt" -> JsWhenMsOrNull(sess.deletedAt),
          "expiredAt" -> JsWhenMsOrNull(sess.expiredAt),
          "version" -> JsNumber(sess.version),
          "startHeaders" -> sess.startHeaders)
    if (inclPart1) {
      json += "part1" -> JsString(sess.part1CompId)
    }
    json
  }


      val nestedQueries: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "manyQueries")
      val nestedActions: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "doActions")
        val (sid, _, sidAndXsrfCookies) =
              security.createSessionIdAndXsrfToken(request, user.id)
          security.createSessionIdAndXsrfToken(req, user.id)
      "weakSessionId" -> JsString(sid.value)))  // [NOCOOKIES]
  sealed abstract class MinAuthnStrength(val IntVal: i32, val fullSidRequired: Bo = true) {
    def toInt: i32 = IntVal
  }


  // RENAME to AuthnStrength? And a *param* name can be minAuthnStrength instead?
  // Then, AuthnStrength.[InternalJob] makes sense, otherwise not.
  //
  object MinAuthnStrength {

    // No session id needed — instead, we check an e2e test secret, for each request.
    case object E2eTestPassword extends MinAuthnStrength(10, fullSidRequired = false)

    // No session id needed — instead, we check an API secret, for each request.
    case object ApiSecret extends MinAuthnStrength(15, fullSidRequired = false)

    /// Parts 1 and 2 of old embedded sessions might have been cached in localStorage
    /// in the embedding website, and if the embedding website has security vulnerabilities,
    /// an attacker might have gained access to those parts of the session.
    /// Therefore, those parts of the session should give access only to
    /// embedded comments — like, posting comments, editing one's comments,
    /// or (for mods) moderating embedded comments [mod_emb_coms_sid],
    /// but nothing else. Then, if a blog has security vulnerabilities, only
    /// the blog comments would be affected — but not any Talkyard forum on its own
    /// sub domain (for organizations with both a forum and blog comments),
    /// or any user accounts or email addresses.
    ///
    /// This could also be configurable, so the site admins could choose to always
    /// require session part 3, or even require people to interact with the comments
    /// iframe, trying to make iOS Safari ITP understand that the comments aren't
    /// a tracker, and allow cookies — then, the server would get the HttpOnly
    /// cookies. [ios_itp]
    ///
    case object EmbeddingStorageSid12 extends MinAuthnStrength(20, fullSidRequired = false)

    /// The embedded sid can be refreshed by popping up a window directly
    /// against the Talkyard server — then, the session id part 3, in a cookie, would
    /// be accessible to the javascript in the popup, which could send it to the iframes.
    /// Or the app server could reply and include parts 1, 2, 3 in the response.
    /// But only sids part 1+2 (without part 3), would get rejected (since they're
    /// less safe, see EmbeddingStorageSid12 above). Part 3 would be temporarily
    /// remembered only in the iframes, never accessible to the embedd*ing* website,
    //case object EmbeddedIframeSid123 extends MinAuthnStrength(30, part3Required = true)

    //case object EmbeddedIframeSid123Recent extends MinAuthnStrength(35, part3Required = true)

    /// Apart from requiring HttpOnly cookies (session id part 4), what Normal
    /// authentication is, would be community specific. Some communities might be ok
    /// with password authn — whilst others might want 2 or 3 factor authn,
    /// and/or SameSite Strict cookies.
    case object Normal extends MinAuthnStrength(40)

    /// Like Normal, but must have authenticated recently. Could protect against
    /// someone grabbing another person's laptop, running away, and then 5 minutes later
    /// trying to change the other person's email address to hens own — this wouldn't work,
    /// since hen would need to authenticate again first (so has authenticated just recently).
    //case object NormalRecent extends MinAuthnStrength(45)

    /// A community can optionally require stronger authentication, for some endpoints
    /// (or maybe even some specific parts of a community?), or custom groups, mods or admins.
    /// By default, Strong is the same as Normal — the site admins would need to
    /// specify somehow what Strong means, in their community's case. Maybe
    /// OTP + 2FA could be a default? Or should WebAuthn be the default Strong method?
    /// (Unlike 2FA in general, WebAuthn works against phishing.)
    //case object Strong extends MinAuthnStrength(50)

    /// Like Strong, but must have authenticated recently — say, within the last X minutes
    /// (and X is community specific).
    /// For things like changing one's email address or deleting one's account.
    //case object StrongRecent extends MinAuthnStrength(55)

    /// Maybe useful if min-auth-strength fn gets called by an internal background job?
    // case object [InternalJob] extends MinAuthnStrength(99)
  }


package talkyard.server

import com.debiki.core._
import play.api.http.{HeaderNames => play_HeaderNames}

package object http {

  val UserAgentHeaderNormalLength = 100


  object HeaderNamesLowercase {

    val UserAgent: St = play_HeaderNames.USER_AGENT.toLowerCase

    val AcceptCH = "accept-ch"

    // ----- User Agent

    // The user agent related headers are:
    // - User-Agent
    // - Sec-CH-UA-Mobile    e.g. "?0"
    // - Sec-CH-UA-Platform  e.g. "Linux"
    // - Sec-CH-UA     e.g. "Google Chrome";v="95", "Chromium";v="95", ";Not A Brand";v="99"

    // Sec-CH-UA, e.g. "Google Chrome";v="95", "Chromium";v="95", ";Not A Brand";v="99"
    val ClientHintUserAgent = "sec-ch-ua"

    // Sec-CH-UA-Mobile: "?0" or "?1"
    val ClientHintUaMobile = "sec-ch-ua-mobile"

    // Sec-CH-UA-Platform: e.g. "Linux"
    val ClientHintUaPlatform = "sec-ch-ua-platform"

    val ClientHintHeaders: Vec[St] = Vec(
          ClientHintUserAgent,
          ClientHintUaMobile,
          ClientHintUaPlatform)

    val ClientHintHeadersAndUserAgent: Vec[St] =
          ClientHintHeaders :+ UserAgent

    // ----- Network related

    val SaveData = "save-data"

    // Effective connection type: slow-2g, 2g, 3g, 4g.
    val Ect = "ect"

    // Approximate round trip time in millis, including application server processing time.
    val Rtt = "rtt"

    // Approximate bandwidth of the client's connection to the server, in Mbps.
    val Downlink = "downlink"
  }

}
/**
 * Copyright (c) 2021 Kaj Magnus Lindberg
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package talkyard.server.sess

import com.debiki.core._
import debiki.RateLimits
import debiki.dao.SiteDao
import debiki.EdHttp._
import ed.server.{EdContext, EdController}
import controllers.Utils.OkApiJson
import play.api.libs.json._
import javax.inject.Inject
import play.api.mvc.{Action, ControllerComponents}
import talkyard.server.JsX._
import debiki.JsonUtils._


class SessionController @Inject()(cc: ControllerComponents, edContext: EdContext)
  extends EdController(cc, edContext) {


  def listSessions(patId: PatId): Action[U] = GetAction { req =>
    import req.{dao, theRequester => reqer}
    throwForbiddenIfMayNot("view", dao, reqer, patId)
    val activeSessions = dao.listPatsSessions(patId)
    val json = Json.obj("sessions" -> JsArray(activeSessions.map(s => JsSession(s))))
    OkApiJson(json)
  }


  def terminateSessions: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
        maxBytes = 2000) { req =>
    import req.{dao, theRequester => reqer}

    val body = asJsObject(req.body, "The request body")
    val forPatId = parseInt32(body, "forPatId")

    throwForbiddenIfMayNot("terminate", dao, reqer, forPatId)

    val terminateAllOthers: Bo = parseOptBo(body, "all") getOrElse false
    val allButNot = if (!terminateAllOthers) None else Some(req.tySession.createdAt)

    val sessionsStartedAtArr: Seq[JsValue] = parseOptJsArray(
          body, "sessionsStartedAt") getOrElse Nil

    val startTimesMs: Seq[When] = sessionsStartedAtArr map { jsVal =>
      When.fromMillis(asInt64(jsVal, "Session start date-time"))
    }

    throwBadReqIf(startTimesMs.nonEmpty && allButNot.isDefined, "TyE4MWJ0202",
          "Don't specify both 'all' and 'sessionsStartedAt'")

    throwBadReqIf(startTimesMs.isEmpty && allButNot.isEmpty, "TyE4MWJ0203",
          "Specify one of 'all' and 'sessionsStartedAt'")

    val terminatedSessions = dao.terminateSessions(
          forPatId, thoseStartedAt = startTimesMs, allButNot)

    val json = Json.obj(
        "terminatedSessions" -> JsArray(terminatedSessions.map(s => JsSession(s))))

    OkApiJson(json)
  }


  private def throwForbiddenIfMayNot(doWhat: St, dao: SiteDao, reqer: Pat, patId: PatId): U = {
    throwForbiddenIf(reqer.id != patId && !reqer.isStaff, "TyE0YOURSESS",
          s"Cannot $doWhat other people's sessions")
    val pat = dao.getTheParticipant(patId)
    throwForbiddenIf(pat.isAdmin && !reqer.isAdmin, "TyEADMINSESS_",
          s"Cannot $doWhat an admin's sessions")
  }

}

/**
 * Copyright (c) 2021 Kaj Magnus Lindberg
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package talkyard.server.sess

import com.debiki.core._
import com.debiki.core.Prelude._
import debiki.EdHttp.urlDecodeCookie
import debiki.dao.SiteDao
import talkyard.server.dao.StaleStuff



trait SessionSiteDaoMixin {
  self: SiteDao =>


  def listPatsSessions(patId: PatId): ImmSeq[TySessionInDbMaybeBad] = {
    val sessionsMaybeBad = readTx(_.loadActiveSessions(patId))
    SECURITY; COULD // also incl recently ended sessions:
    //val recentlyActiveSessions = readTx(_.loadRecentlyEndedSessions(patId, limit = 10)) ?

    // Lazy-mark as expired sessions that has just expired.  [lazy_expire_sessions]
    val settings = getWholeSiteSettings()
    val now = this.now()
    val (justExpiredSessions, activeSessions) = sessionsMaybeBad.partition(
          _.expiresNow(now, expireIdleAfterMins = settings.expireIdleAfterMins))
    for (session <- justExpiredSessions) {
      val sessionExpired = session.copy(expiredAt = Some(now))
      updateSession(sessionExpired)
    }

    activeSessions
  }

  def getSessionByPart1(part1Maybe2Or3: St): Opt[TySessionInDbMaybeBad] = {
    readTx(_.loadSession(part1Maybe2Or3 = Some(part1Maybe2Or3)))
     // + cache
  }


  /// If maybeActiveOnly, won't load sessions that have been terminated for sure,
  /// but does load sessions that might have expired, but that has not yet been
  /// marked as expired in the database.
  ///
  def getSessionByPart4HttpOnly(part4: St, maybeActiveOnly: Bo): Opt[TySessionInDbMaybeBad] = {
    readTx(_.loadSession(part4HttpOnly = Some(part4), maybeActiveOnly = maybeActiveOnly))
     // + cache
  }


  def insertValidSession(session: TySession): U = {
    writeTx { (tx, _) =>
     tx.insertValidSession(session)
     // + cache
    }
  }


  def updateSession(session: TySessionInDbMaybeBad): U = {
    writeTx { (tx, _) =>
     tx.upsertSession(session)

     // + uncache
    }
  }


  def terminateSessionForCurReq(req: play.api.mvc.RequestHeader): U = {
    REFACTOR // Move these cookie names — and their whole file — to this pkg.
    // Thereafter, can remove this import.
    import ed.server.security.EdSecurity._

    val sidPart12Maybe3FromHeader: Opt[St] = req.headers.get(SessionIdHeaderName)
    val sidPart123FromCookie: Opt[St] =
          urlDecodeCookie(SessionIdPart123CookieName, req)

    val sidPart4FromCookie: Opt[St] =
          urlDecodeCookie(SessionIdPart4HttpOnlyCookieName, req)

    // Maybe some day some bug can cause the browser to send two different
    // session part 1 values. Then we'd better delete both sessions.
    sidPart12Maybe3FromHeader foreach { partsInHeader =>
      val twoDifferentPart1 = sidPart123FromCookie.exists(!_.startsWith(partsInHeader))
      if (twoDifferentPart1) {
        AUDIT_LOG // Is this weird? A script might have has tampered with the cookie
        // or header?  For now, to catch bugs:
        warnDevDie("TyE70MWEPG246")
      }
      terminateAnySession(
            sidPart1Maybe2Or3 = Some(partsInHeader),
            sidPart4 = None)
    }

    terminateAnySession(
          sidPart1Maybe2Or3 = sidPart123FromCookie,
          sidPart4 = sidPart4FromCookie)
  }


  private def terminateAnySession(sidPart1Maybe2Or3: Opt[St], sidPart4: Opt[St]): U = {
    writeTx { (tx, _) =>
      val sessions = tx.loadOneOrTwoSessions(sidPart1Maybe2Or3, part4HttpOnly = sidPart4,
            maybeActiveOnly = true)
      assert(sessions.forall(_.wasValidJustRecently))  // could remove the filter() below
      val terminatedSessions =
            sessions.filter(_.wasValidJustRecently)
                .map(_.copy(deletedAt = Some(tx.now)))
      terminatedSessions foreach tx.upsertSession

       // + uncache
    }
    AUDIT_LOG // 0, 1 or 2 sessions got terminated.
  }


  /// Returns any sessions that got terminated; isDeleted will be Some(current-time).
  /// This might include sessions that expired just moments ago — they'll
  /// be both expiredAt = Some(..) and deletedAt = Some(..).
  ///
  def terminateSessions(forPatId: PatId, thoseStartedAt: Seq[When] = Nil,
          allExceptFor: Opt[When] = None, all: Bo = false,
          anyTx: Opt[(SiteTx, StaleStuff)] = None)
          : ImmSeq[TySessionInDbMaybeBad] = {

    dieIf(all && thoseStartedAt.nonEmpty, "TyE60MWEPJ22")
    dieIf(all && allExceptFor.isDefined, "TyE60MWEPJ23")
    dieIf(thoseStartedAt.nonEmpty && allExceptFor.isDefined, "TyE60MWEPJ24")

    val sessionsMaybeBad = readTxTryReuse(anyTx.map(_._1))(_.loadActiveSessions(forPatId))
    val sessionsToEnd = sessionsMaybeBad filter { sess =>
      warnDevDieIf(!sess.wasValidJustRecently, "TyE5MWJY20X")
      all || thoseStartedAt.contains(sess.createdAt) ||
            allExceptFor.isSomethingButNot(sess.createdAt)
    }

    val now = anyTx.map(_._1.now) getOrElse this.now()
    val terminatedSessions = sessionsToEnd.map(_.copy(deletedAt = Some(now)))

    if (terminatedSessions.nonEmpty) {
      writeTxTryReuse(anyTx) { (tx, _) =>
        terminatedSessions foreach tx.upsertSession

        // + uncache
      }
    }

    terminatedSessions
  }

}
// Maybe store this in Me instead? Next to Me.mySidPart1? [sess_in_me]
        isLoggedIn: store.me.isLoggedIn,
    /* COULD_OPTIMIZE: Clear these cookies, maybe all authn cookies? [clear_aun_cookies]
     * Clear cookies both server side — and from here too, in case closing the dialog
     * without logging in?
     * (This close() runs also after the create-user dialog, via:
     * closeDialog('CloseAllLoginDialogs'))
    getSetCookie('dwCoIsInLoginWindow', null);
    getSetCookie('TyCoAvoidCookies', null);
     */
      const hasSid = me_hasSid();
      // + "[x] Log out from all devices"  — if there're other sessions as well
const securityPathSeg = 'security';
    const securityPath = prefsPathSlash + securityPathSeg;
      Route({ path: '(.*)/' + securityPathSeg, exact: true, render: () => SecurityPrefsTab(childProps) }),
              isGroupGuestOrBuiltIn ? null : LiNavLink({
                  to: securityPath, className: 'e_UP_Prf_Nav_SecL' }, t.upp.Security),

const SecurityPrefsTab = React.createFactory<any>(function(props: {
        user: UserInclDetails, store: Store }) {

  const store = props.store;
  const user = props.user;

  const [sessionsOrNull, setSessions] = React.useState<Session[] | N>(null);

  // Break out hook? [my_cur_id]
  const me = store.me;
  const myIdRef = React.useRef(me.id);

  React.useEffect(() => {
    myIdRef.current = me.id;
    listPatsSessions();
    return () => myIdRef.current = null;
  }, []);//, [me.id, user.id]);

  function listPatsSessions() {
    // If one is admin, then, `me` can be !== `user`.
    Server.listSessions(user.id, (resp: ListSessionsResponse) => {
      if (myIdRef.current !== me.id) return;
      // Show most recent first.
      const sessionsByTime = [...resp.sessions].sort(
              function(a,b ) { return b.createdAt - a.createdAt; });
      setSessions(sessionsByTime);
    });
  }

  if (!sessionsOrNull)
    return r.p({}, t.Loading);

  const sessions: Session[] = sessionsOrNull;
  let numActive = 0;

  const sessionItems = sessions.map((session: Session) => {
    if (!session.deletedAt && !session.expiredAt) numActive += 1;
    return r.li({ key: session.createdAt },
        SessionInfo(session, endSession, me));
  });

  function endSession(ps: { session?: Session, all?: true }) {
    const sessionsStartedAt = ps.session && [ps.session.createdAt];
    Server.terminateSessions({ forPatId: user.id, sessionsStartedAt, all: ps.all },
            (resp: TerminateSessionsResponse) => {
      if (myIdRef.current !== me.id) return;
      // Is sorted by time already, see sort(..) above.
      const sessionsAfter = arr_replaceMany(sessions,
              resp.terminatedSessions, (s: Session) => s.createdAt);
      setSessions(sessionsAfter);
    });
  }

  const isMyOnlySession = me.id === user.id && numActive === 1;

  const logOutEverywhereBtn = !numActive || isMyOnlySession ? null :
      Button({ className: 'c_SessL_EndAllB', onClick: () => endSession({ all: true })},
          "Log out everywhere (but not here)");   // I18N

  return (
    r.div({},
      // Later: "You're logged in on these devices:"
      // Or: "You are logged in on these devices, or were recently:"
      // Or: "Active or recently active devices:"  ?
      r.h2({}, "Active sessions:"),  // I18N
      r.ol({ className: 'c_SessL' }, sessionItems),
      sessionItems.length ? null : r.p({}, "None"), // I18N
      logOutEverywhereBtn,
      ));
});


function SessionInfo(session: Session, endSessionFn: (ps: { session: Session }) => V, me: Me) {
  const createdAt: St = new Date(session.createdAt).toISOString();
  let activeOrEnded: St;

  let deletedAt = session.deletedAt;
  let expiredAt = session.expiredAt;
  if (deletedAt && expiredAt) {
    if (deletedAt <= expiredAt) expiredAt = null;
    else deletedAt = null;
  }

  const isCurrent = me.mySidPart1 !== session.part1 ? '' :
          " — this session, here";

  let terminateBtn: RElm | U;
  let activeOrEndedClass = 'c_SessL_Sess-Ended';

  if (deletedAt) {
    activeOrEnded = " — got deleted at " + new Date(deletedAt).toISOString();
  }
  else if (expiredAt) {
    activeOrEnded = " — expired at " + new Date(expiredAt).toISOString();
  }
  else {
    activeOrEnded = isCurrent ? '' : " — currently active";
    activeOrEndedClass = 'c_SessL_Sess-Active';

    // Skip logout button for the current session. It's better if one stays logged
    // in and can see that the relevant sessions got terminated properly. And
    // thereafter click Log Out in one's username menu. (Or?)
    terminateBtn = me.mySidPart1 === session.part1 ? null :
          Button({ className: 'c_SessL_Sess_EndB',
              onClick: () => endSessionFn({ session }) }, "Log out");  // I18N
  }

  let debugJson = null;
  // @ifdef DEBUG
  debugJson = r.pre({}, JSON.stringify(session, undefined, 3));
  // @endif

  return r.div({ className: 'c_SessL_Sess ' + activeOrEndedClass },
      r.span({}, "Session started at " + createdAt + isCurrent + activeOrEnded),
      debugJson,
      terminateBtn);
}



    // Or if our session just got terminated from another device?
    // To try to make this happen, see /^sessions/ in tests-map.txt.
    dieIf(!anyMe, 'Server.loadMyself() returned me = null [TyE4032SMH57]');
    // Maybe?: if (!anyMe) return;
    // Or?: if (!anyMe) logoutClientSideOnly()

/// Not currently in use? But maybe call this one instead of
/// Server.logoutServerAndClientSide() drectly?
  Server.deleteTempSessId();  // [is_logging_out]
/// Returns parts 1 and 2 of any current session id, maybe 3. (Parts 4 and 5 are HttpOnly
/// cookies, not accessible here.)
///
export function getCurSid12Maybe3(): St | N {  // [ts_authn_modl]
  const store: Store = debiki2.ReactStore.allData();
  const cookieName =
          debiki2.store_isFeatFlagOn(store, 'ffUseNewSid') ? 'TyCoSid123' : 'dwCoSid';
  let sid = getSetCookie(cookieName);
  if (!sid) {
    // Cannot use store.me.mySidPart1 — we might not yet have loaded
    // the current user from the server; store.me might be stale.
    const typs: PageSession = getMainWin().typs;
    // This might not include part 3 (won't, if we're in an embedded comments
    // iframe, and didn't login or resume via a popup win directly against the
    // server so we could access cookie TyCoSid123, which includes part 3).
    sid = typs.weakSessionId;
  }
  return sid || null;
}


  // Need not delete store.me.mySidPart1 — we'll reload the page anyway. [is_logging_out]
    getSetCookie('TyCoSid123', null);
export function loadEmailAddressesAndLoginMethods(userId: UserId, onOk: UserAcctRespHandler,
          onErr?: (resp: A) => V) {
  get(`/-/load-email-addrs-login-methods?userId=${userId}`, onOk, onErr);
export function addEmailAddresses(userId: UserId, emailAddress: St, onOk: UserAcctRespHandler,
        onErr: (resp: A) => V) {
  postJsonSuccess('/-/add-email-address', onOk, onErr, { userId, emailAddress });
export function suspendUser(userId: UserId, numDays: Nr, reason: St, onOk: () => V,
        onErr: (resp: A) => V) {
  postJsonSuccess('/-/suspend-user', onOk, onErr, {
    // onErr(() => send 'failedToLogin' to parent frame)  [forget_sid12]
}


export function listSessions(patId: PatId, onOk: (resp: ListSessionsResponse) => V,
        onErr: () => V) {
  get(`/-/list-sessions?patId=${patId}`, onOk, onErr);
}


export function terminateSessions(
        ps: { forPatId: PatId, sessionsStartedAt?: WhenMs[], all?: true },
        onOk: (response: TerminateSessionsResponse) => V, onErr: () => V) {
  postJsonSuccess(`/-/terminate-sessions`, onOk, ps, onErr);
        // Reply 'failedToLogin' if login failed  [forget_sid12]
      if (!me_hasSid()) {
            `No session cookie, no typs.weakSessionId. ` +
    case 'logoutServerAndClientSide':
      Server.logoutServerAndClientSide();
      break;
        // We've remembered any weakSessionId already, see [5028KTDN306].
/// I can haz session id?
///
export function me_hasSid(): Bo {
  return !!(
        getSetCookie('dwCoSid') ||        // old
        getSetCookie('TyCoSid123') ||     // new, better  [btr_sid]
        getMainWin().typs.weakSessionId); // if cookies don't work
}


  weakSessionId?: St;  // RENAME to sid12Maybe3 ?
/// Either compares two items (if function.length === 2) or compares
/// a field value (if length === 1).
type ArrItemIsSameFn<Item> = ((a: Item, b: Item) => Bo) | ((it: Item) => any);

  // This is not the whole session id — it's the first 16 chars only [sid_part1];
  // the remaining parts have (a lot) more entropy than necessary.
  mySidPart1?: St | N;
interface Session {
  patId: PatId;
  createdAt: WhenMs;
  version: Nr,
  startHeaders: { [name: St]: St };
  part1: St;
  deletedAt?: WhenMs;
  expiredAt?: WhenMs;
}


interface ListSessionsResponse {
  sessions: Session[];
}


interface TerminateSessionsResponse {
  terminatedSessions: Session[];
}


  const offFlag = '0' + featureFlag;
  const isOn = _.includes(store.siteFeatureFlags, featureFlag) ||
  const isOff = _.includes(store.siteFeatureFlags, offFlag) ||
         _.includes(store.serverFeatureFlags, offFlag);
  return isOn && !isOff;   // [ff_on_off]
declare function getSetCookie(cookieName: St, value?: St, options?: Ay): St | Nl;
    RENAME to arr_groupByKeepOne ?
export function arr_replaceMany<Item>(
        arr: Item[], newerItems: Item[], isSame: ArrItemIsSameFn<Item>): Item[] {
  const newArr = [...arr];
  arr_replaceManyInPl(newArr, newerItems, isSame);
  return newArr;
}


export function arr_replaceManyInPl<Item>(
        arr: Item[], newerItems: Item[], isSame: ArrItemIsSameFn<Item>) {
  for (const newerItem of newerItems) {
    arr_replaceOneInPl(arr, newerItem, isSame);
  }
}


export function arr_replaceOneInPl<Item>(
        arr: Item[], newerItem: Item, isSame: ArrItemIsSameFn<Item>) {
  const fnLen = isSame.length;
  // @ifdef DEBUG
  dieIf(fnLen < 1 || 2 < fnLen, 'TyE60MEPWH32');
  // @endif

  const getFieldVal = fnLen === 1 ? isSame as ((item: Item) => A) : undefined;
  const newerFieldVal = getFieldVal && getFieldVal(newerItem);

  const ix = _.findIndex(arr, function(oldItem) {
    if (getFieldVal) return getFieldVal(oldItem) === newerFieldVal;
    else return isSame(oldItem, newerItem);
  });

  if (ix >= 0) {
    arr[ix] = newerItem;
  }
}


// REFACTOR use arr_replaceOneInPl & x => x.id  instead.
// + add an IfNotFound.ThenDie/ThenAppend/ThenIgnore param? Default to ThenDie?
  function arr_replaceMany<Item>(arr: Item[],
        newerItems: Item[], isSame: ArrItemIsSameFn<Item>): Item[];

  function arr_replaceManyInPl<Item>(arr: Item[],
        newerItems: Item[], isSame: ArrItemIsSameFn<Item>);

  function arr_replaceOneInPl<Item>(arr: Item[],
        newerItem: Item, isSame: ArrItemIsSameFn<Item>);
  function me_hasSid(): Bo;
/// <reference path="oop-methods.ts" />


  const store: Store = debiki2.ReactStore.allData();
  const me: Myself = store.me;

  // New style session id:  [btr_sid]
  // ------------------------------------------
  if (debiki2.store_isFeatFlagOn(store, 'ffUseNewSid')) {
    const sidParts123 = debiki2.Server.getCurSid12Maybe3();
    const stillTheSameSid = sidParts123
          ? me.mySidPart1 && sidParts123.startsWith(me.mySidPart1)
          : !me.mySidPart1;
    if (!stillTheSameSid) {
      if (sidParts123) {
        // The human has logged in.
        if (me.mySidPart1) {
          // The human was already logged in. Then it's good to reload the page so
          // stuff related to the previous user, disappears from the browser memory.
          location.reload();
        }
        else {
          // The human wasn't logged in. We can load the human's data without page reload.
          debiki2.ReactActions.loadMyself();
        }
      }
      else {
        // The human was logged in, but has now logged out. (This reloads the
        // page, or loads a new page, and forgets all in-mem state.)
        debiki2.ReactActions.logoutClientSideOnly();
      }
    }
    return;
  }

  // Old style session id:
  // ------------------------------------------
  if (me.isLoggedIn) {
      if (me.id !== parseInt(newUserIdString)) {

    Security: string;
// For the user to stay logged in, Ty can store not the whole but parts 1 and 2 of
// the session id in localStorage, if 3rd party cookies disabled.
// See docs/ty-security.adoc for more about sessions and parts 1, 2, 3, 4 and 5.
// [NOCOOKIES] Parts 1 and 2 don't let the user do much more than accessing and
// posting blog comments, and moderating blog comments if hen's a moderator
// (but won't give access to, for example, the admin area).
// Some more things to do: [restrict_sid_part_2_more].  [sid_part_3]
  window.addEventListener('storage', onLocalStorageChanged);
let curSessItemInStorage: St | Nl = null;

function onLocalStorageChanged() {
  // Maybe we logged out in another browser tab?
  /* First fix: [forget_sid12]
  const maybeNewSessItem: St | Nl = theStorage.getItem('talkyardSession');
  if (curSessItemInStorage !== maybeNewSessItem) {
    if (maybeNewSessItem) {
      sendToComments(['resumeWeakSession', maybeNewSessItem]);
    }
    else {
      sendToComments(['logoutServerAndClientSide', null]);
      sendToEditor(['logoutClientSideOnly', null]);
    }
    curSessItemInStorage = maybeNewSessItem;
  } */
}

          // Skip this hereafter?! [btr_sid] Do afterwards instead, if is now invalid.
          // Because otherwise we'd get logged out in other tabs — they listen to localStorage.
          // The iframe can send back a 'failedToLogin' instead?  [forget_sid12]
          curSessItemInStorage = null;
      // @ifdef DEBUG
      // Here, session id part 3 must not be included — it must not be seen
      // by the embedding website (only by code directly on the Talkyard domain).
      // So, the length should be:  SidLengthCharsPart1 + SidLengthCharsPart2 = 16 + 24
      // for the new fancy sessions. Whilst the old silly sids include a '.' dot.
      if (eventData.weakSessionId && eventData.weakSessionId.length !== 16 + 24
            && eventData.weakSessionId.indexOf('.') === -1)
        throw Error(`tySid12 should be 16 + 24 = ${16 + 24} chars but is ${
                eventData.weakSessionId.length} chars [TyEBADSID12LEN]`);
      // @endif
          curSessItemInStorage = JSON.stringify(item);
          theStorage.setItem('talkyardSession', curSessItemInStorage);

          // ! Need a 'failedToLogin' if login failed  [forget_sid12]
        curSessItemInStorage = null;
type V  = void; // ... hmm but really really not often. Let's use 'Va' for such templ params?
type A = any;   // Why not A! Let's use 'A'.
GET   /-/list-sessions          talkyard.server.sess.SessionController.listSessions(patId: Int)
POST  /-/terminate-sessions     talkyard.server.sess.SessionController.terminateSessions
# Maybe move /-/logout to SessionController?

[gdpr]
New table: sessions_t, impl fn deletePersonalDataFromOldSessions().  [sess_deleted_ix]

see: SessionType.AutoTokenSiteCustomSso, and: SidOk(TySession.ApiSecretPart12, ..)
Also see: [weaksid]. [which_ws_session]
Add [sess_deleted_ix]
And [btr_sid].
Missing:  [forget_sid12] [restrict_sid_part_2_more] [bump_sid_last_use]
[ios_itp]
Trigger happy tracking prevention produces problems.
Maybe maybe Safari [ios_itp] will sometimes mistake email verification links
or SSO url params, for being click trackers — e.g. Talkyard's
#talkyardOneTimeLoginSecret=... hash fragment param. See:
https://webkit.org/blog/9521/intelligent-tracking-prevention-2-3/
— then, Safari would delete the Ty session parts 1+2 from localStorage,
after a day.
https://www.cookiestatus.com/safari/
`reidr` means "Re-Enter Iframe in case you just Debug-Reloaded the page".
sessions:
  list one's active sessions,
  terminate one's other sessions:
            - d.sessions-logout-elsewhere.4br.ts  TyTESESLGOSELF
  staff can terminate others' sessions:
            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR
  mods cannot terminate admins sessions:
            - TESTS_MISSING  TyTESESTERMADM
            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR  TyEADMINSESS_
  require session part 4,
  a stolen embdding storage sid part 1+2 is pretty useless:
            - embcom.sessions-emb-sess-cannot-moderate.3br  TyTEECSESS0MOD
  require part 4 HttpOnly for admin stuff:
            - TESTS_MISSING
  require part 4 HttpOnly for non-embedded comments stuff:
            - TESTS_MISSING, and partly UNIMPL
  part 123 gone, client side offline logout:
            - sso-test  TyT4ABKRW0268.TyTESESS123GONE
            - TESTS_MISSING could add tests for this specifically
  part 123 bad,
  part 4 or 5 bad or gone:
            - TESTS_MISSING  TyTSESSHALFBAD
  sorted by recently active first, on one's user page:
            - TESTS_MISSING
  session terminated if user suspended or deleted:  [.del_susp_usr]
            - TESTS_MISSING


  from page to one's active sessions list:
            - d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
deleted users:  [.del_susp_usr]
            - SEC_TEST_MISSING
  terminate someone else's sessions:
            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR

  no more WebSocket messages,
    if account deleted,
    or if suspended,
    or session terminated:
          - SEC_TESTS_MISSING  TyTE0MOREWSMSG
  const skip2And3Browsers = ['!.2br', '!.3br', '!.4br'];  // and skip 4 as well
  // ----- 4 browsers

  // Just these, currently:
  // d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
  // d.sessions-staff-logout-others.4br  TyTESESLGOOTR

  next = ['.4br', ...skipEmbAndAlways];

  withSpecsMatching(next, (specs: St[]): ExitCode => {
    return runWdioInForeground(specs, '--4browsers');   // UNTESTED
  });


  $r s/wdio-7 --only d.sessions-logout-elsewhere.4br --cd -i $args
  $r s/wdio-7 --only d.sessions-staff-logout-others.4br --cd -i $args

  # Stealing an embedded session id
  $r s/wdio-7     --only embcom.sessions-emb-sess-cannot-moderate.3br --cd -i $args


      // Maybe better end any sessions — although aren't any.
      dao.terminateSessions(forPatId = member1.id, all = true)

/// <reference path="../test-types.ts"/>

import * as _ from 'lodash';
import assert from '../utils/ty-assert';
import server from '../utils/server';
import { buildSite } from '../utils/site-builder';
import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';

let brA: TyE2eTestBrowser;
let brB: TyE2eTestBrowser;
let brC: TyE2eTestBrowser;
let brD: TyE2eTestBrowser;
let owen: Member;
let owen_brA: TyE2eTestBrowser;
let owen_brB: TyE2eTestBrowser;
let owen_brC: TyE2eTestBrowser;
let maja: Member;
let maja_brD: TyE2eTestBrowser;

let site: IdAddress;
let forum: TwoCatsTestForum;



describe(`d.sessions-logout-elsewhere.4br  TyTESESLGOSELF`, () => {

  it(`Construct site`, async () => {
    const builder = buildSite();
    forum = builder.addTwoCatsForum({
      title: "Some E2E Test",
      members: ['owen', 'maja'],
    });

    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
    brD = new TyE2eTestBrowser(wdioBrowserD, 'brD');

    owen = forum.members.owen;
    owen_brA = brA;
    owen_brB = brB;
    owen_brC = brC;

    maja = forum.members.maja;
    maja_brD = brD;

    assert.refEq(builder.getSite(), forum.siteData);
  });

  it(`Import site`, async () => {
    site = server.importSiteData(forum.siteData);
    server.skipRateLimits(site.id);
  });


  it(`Owen logs in to the admin area, in browser A ...`, async () => {
    await owen_brA.adminArea.settings.login.goHere(site.origin, { loginAs: owen });
  });
  it(`... and to the forum, in browser B`, async () => {
    await owen_brB.go2(site.origin);
    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
  });
  it(`... and C`, async () => {
    await owen_brC.go2(site.origin);
    await owen_brC.complex.loginWithPasswordViaTopbar(owen);
  });


  it(`Maja logs in, in browser D`, async () => {
    await maja_brD.go2(site.origin);
    await maja_brD.complex.loginWithPasswordViaTopbar(maja);
  });


  it(`Owen starts editing admin settings in browser A, without saving`, async () => {
    await owen_brA.adminArea.settings.login.setApproveUsers(true);
  });
  it(`... and starts composing a new topic in browser B`, async () => {
    await owen_brB.forumButtons.clickCreateTopic();
    await owen_brB.editor.editTitle("Title");
    await owen_brB.editor.editText("Text");
  });


  // ----- List active sessions

  it(`In browser C, Owen navigates to his profile page`, async () => {
    await owen_brC.topbar.clickGoToProfile();
  });
  it(`... the active sessions list`, async () => {
    await owen_brC.userProfilePage.tabs.switchToPreferences();
    await owen_brC.userProfilePage.preferences.tabs.switchToSecurity();
  });

  it(`Owen sees 3 active sessions`, async () => {
    const counts = await owen_brC.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 3, numEnded: 0 });
  });


  // ----- Terminate all one's other sessions

  it(`Owen treminates all sessions except for browser C`, async () => {
    await owen_brC.userProfilePage.preferences.security.terminateAllSessions();
  });

  it(`... now he sees one active session, and 2 terminated sessions`, async () => {
    // It takes a while for the server to reply, and the UI to update.
    await owen_brC.userProfilePage.preferences.security.waitForSessions({
            numActive: 1, numEnded: 2 });
  });
  it(`... after page reload ...`, async () => {
    await owen_brC.refresh2();
    await owen_brC.userProfilePage.preferences.security.waitForLoaded();
  });
  it(`... the terminated sessions are gone — terminated sessions aren't shown`, async () => {
    const counts = await owen_brC.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 1, numEnded: 0 });
  });


  // ----- Cannot use terminated sessions

  it(`In browser A, Owen tries to save the admin settings`, async () => {
    await owen_brA.adminArea.settings.clickSaveAll({ willFail: true });
  });
  it(`... there's an error: Owen isn't logged in as admin (in brA)`, async () => {
    await owen_brA.serverErrorDialog.waitForNotLoggedInAsAdminError();
  });
  it(`... after page refresh, he sees the login dialog (in brA)`, async () => {
    await owen_brA.refresh2();
    await owen_brA.loginDialog.waitAssertFullScreen();
  });

  it(`In browser B, Owen tries to save the topic`, async () => {
    await owen_brB.editor.save();
  });
  it(`... there's a not-logged-in error in brB`, async () => {
    await owen_brB.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after page refresh, he sees he's logged out in brB`, async () => {
    await owen_brB.refresh2();
    await owen_brB.topbar.waitUntilLoginButtonVisible();
  });


  // ----- Login again

  it(`Owen logs in again, in browser B`, async () => {
    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
  });
  it(`... and logs in to his sessions list page, in browser A ...`, async () => {
    await owen_brA.userProfilePage.preferences.security.goHere(owen.username, { loginAs: owen });
  });

  it(`There are again 3 active sessions`, async () => {
    const counts = await owen_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 3, numEnded: 0 });
  });


  // ----- Session sort order

  it(`... Owen's brA session is in the middle   TESTS_MISSING
              because sessions are sorted by created-at currently`, async () => {
  });


  // ----- Terminate a specific session

  it(`Owen terminates the oldest session, the one in brC`, async () => {
    await owen_brA.userProfilePage.preferences.security.terminateOldestSession();
  });
  it(`... thereafter, 2 active and 1 teminated sessions listed`, async () => {
    await owen_brA.userProfilePage.preferences.security.waitForSessions({
            numActive: 2, numEnded: 1 });
  });
  it(`ttt: There're 2 terminate-session buttons: One to terminate "all", and one
              to terminate the one and only other session`, async () => {
    const n = await owen_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 2);
  });

  it(`Now, in brC, Owen tries to switch to the Drafts Etc tab`, async () => {
    await owen_brC.userProfilePage.tabs.switchToDraftsEtc({ willFail: true });
  });
  it(`... there's an error: He's no longer logged in, in that browser`, async () => {
    await owen_brC.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after reload ...`, async () => {
    await owen_brC.refresh2();
  });
  it(`... the login button appears`, async () => {
    await owen_brC.topbar.waitUntilLoginButtonVisible();
  });


  // ----- One's other sessions still work

  it(`But in brB, Owen is still logged in: He can submit a topic`, async () => {
    await owen_brB.complex.createAndSaveTopic({ title: "Owen's Topic", body: "Text." });
  });
  it(`... and after reload ...`, async () => {
    await owen_brB.refresh2();
  });
  it(`... he's still logged in — sees his username menu`, async () => {
    assert.eq(await owen_brB.topbar.getMyUsername(), owen.username);
  });


  // ----- Other users not affected

  it(`Maja is still logged in — she can post a topic`, async () => {
    await maja_brD.complex.createAndSaveTopic({ title: "Maja's Topic", body: "Text." });
  });
  it(`... and reload the page`, async () => {
    await maja_brD.refresh2();
  });
  it(`... she still sees her username — she's still logged in`, async () => {
    await maja_brD.topbar.assertMyUsernameMatches(maja.username);
  });


  // ----- Session disappears from list, after logging out in other browser

  it(`Owen logs out in browser B`, async () => {
    await owen_brB.topbar.clickLogout();
  });

  it(`... and reloads the session list in browser A`, async () => {
    await owen_brA.refresh2();
  });
  it(`... now there's just one active session — his current session, brA`, async () => {
    const counts = await owen_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(counts, { numActive: 1, numEnded: 0 });
  });

  it(`There's no end-session button — cannot end one's current session here`, async () => {
    const n = await owen_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 0);
  });

});
/// <reference path="../test-types.ts"/>

import * as _ from 'lodash';
import assert from '../utils/ty-assert';
import server from '../utils/server';
import { buildSite } from '../utils/site-builder';
import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
import settings from '../utils/settings';

let brA: TyE2eTestBrowser;
let brB: TyE2eTestBrowser;
let brC: TyE2eTestBrowser;
let brD: TyE2eTestBrowser;
let owen: Member;
let owen_brB: TyE2eTestBrowser;
let mons: Member;
let mons_brA: TyE2eTestBrowser;
let maja: Member;
let maja_brB: TyE2eTestBrowser;
let mallory: Member;
let mallory_brC: TyE2eTestBrowser;
let mallory_brD: TyE2eTestBrowser;

let site: IdAddress;
let forum: TwoCatsTestForum;



describe(`d.sessions-staff-logout-others.4br  TyTESESLGOOTR`, () => {

  it(`Construct site`, async () => {
    const builder = buildSite();
    forum = builder.addTwoCatsForum({
      title: "Some E2E Test",
      members: ['owen', 'maja', 'mons', 'mallory'],
    });

    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');
    brD = new TyE2eTestBrowser(wdioBrowserD, 'brD');

    owen = forum.members.owen;
    owen_brB = brB;

    mons = forum.members.mons;
    mons_brA = brA;

    maja = forum.members.maja;
    maja_brB = brB;

    mallory = forum.members.mallory;
    mallory_brC = brC;
    mallory_brD = brD;

    assert.refEq(builder.getSite(), forum.siteData);
  });

  it(`Import site`, async () => {
    site = server.importSiteData(forum.siteData);
    server.skipRateLimits(site.id);
  });


  // ----- (Maja and Mallory log in)

  it(`Maja logs in, in browser B`, async () => {
    await maja_brB.go2(site.origin);
    await maja_brB.complex.loginWithPasswordViaTopbar(maja);
  });

  it(`Mallory too, in browser C ...`, async () => {
    await mallory_brC.go2(site.origin);
    await mallory_brC.complex.loginWithPasswordViaTopbar(mallory);
  });

  it(`Mallory logs in in browser D too`, async () => {
    await mallory_brD.userProfilePage.preferences.goHere(
            mallory.username, { origin: site.origin });
    await mallory_brD.complex.loginWithPasswordViaTopbar(mallory);
  });
  it(`... starts editing his name`, async () => {
    await mallory_brD.userProfilePage.preferences.setFullName('Admin of Admins');
  });


  // ----- Cannot view someone else's sessions (unless one is mod)

  it(`Mallory types the URL path to Maja's Security tab, in his other browser, C`, async () => {
    await mallory_brC.userProfilePage.preferences.security.goHere(maja);
  });

  it(`... there's nothing here to see — cannot view others' sessions`, async () => {
    await mallory_brC.userProfilePage.waitForTabsVisible();
    assert.not(await mallory_brC.userProfilePage.tabs.isPreferencesTabDisplayed());
    assert.not(await mallory_brC.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
  });

  it(`Mallory returns to the forum topic list instead (in browser C)`, async () => {
    await mallory_brC.topbar.clickBack();
  });


  // ----- A mod terminates a specific session

  it(`Moderator Mons goes to Mallory's profile page`, async () => {
    await mons_brA.userProfilePage.preferences.goHere(mallory.username, { origin: site.origin });
    await mons_brA.complex.loginWithPasswordViaTopbar(mons);
  });
  it(`... sees the Preferences tab  ttt`, async () => {
    await mons_brA.userProfilePage.waitForTabsVisible();
    // ttt — see assert.not(..) above.
    assert.that(await mons_brA.userProfilePage.tabs.isPreferencesTabDisplayed());
  });
  it(`... and the About and Security tab`, async () => {
    await mons_brA.userProfilePage.preferences.tabs.waitForAboutTabDisplayed();
    // ttt — see assert.not(..) above.
    assert.that(await mons_brA.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
  });
  it(`... switches to the Security tab`, async () => {
    await mons_brA.userProfilePage.preferences.tabs.switchToSecurity();
  });

  it(`Mons sees Mallory's 2 sessions`, async () => {
    const num = await mons_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(num, { numActive: 2, numEnded: 0 });
  });

  it(`Mons terminates Mallory's newest session (that's browser D)`, async () => {
    await mons_brA.userProfilePage.preferences.security.terminateNewestSession();
  });

  it(`... afterwards, he sees one active and one terminated session`, async () => {
    await mons_brA.userProfilePage.preferences.security.waitForSessions({
            numActive: 1, numEnded: 1 });
  });


  // ----- Cannot use a terminated session

  it(`Mallory tries to save his username (in browser D)`, async () => {
    await mallory_brD.userProfilePage.preferences.clickSave();
  });
  it(`... there's an error: Mallory isn't logged in (now in brD)`, async () => {
    await mallory_brD.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after page refresh ...`, async () => {
    await mallory_brD.refresh2();
  });
  it(`... he sees the login button (in brD)`, async () => {
    await mallory_brD.me.waitUntilKnowsNotLoggedIn();
    await mallory_brD.topbar.waitUntilLoginButtonVisible();  // ttt
  });

  it(`In browser C, Mallory is still logged in — he posts a new topic`, async () => {
    await mallory_brC.complex.createAndSaveTopic({
          title: "Mallory's Place", body: "This is my palace, I, the Admin of Admins" });
  });


  // ----- (Logging in again)

  it(`Mallory logs in again in browser D`, async () => {
    await mallory_brD.complex.loginWithPasswordViaTopbar(mallory);
  });


  // ----- A mod terminates all a user's sessions

  it(`Mons refreshes the sessions list`, async () => {
    await mons_brA.refresh2();
  });

  it(`... sees Mallory is back, two active sessions`, async () => {
    const num = await mons_brA.userProfilePage.preferences.security.countSessions();
    assert.deepEq(num, { numActive: 2, numEnded: 0 });
  });

  it(`... there're terminate buttons for all sessions, plus an all-in-one-click`, async () => {
    const n = await mons_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 2 + 1);
  });

  it(`Mons terminates all Mallory's sessions`, async () => {
    await mons_brA.userProfilePage.preferences.security.terminateAllSessions();
  });

  it(`... afterwards, no more active sessions, 2 terminated`, async () => {
    await mons_brA.userProfilePage.preferences.security.waitForSessions({
            numActive: 0, numEnded: 2 });
  });

  it(`... no more terminate buttons`, async () => {
    const n = await mons_brA.userProfilePage.preferences.security.numEndSessionButtonsVisible();
    assert.eq(n, 0);
  });


  // ----- Cannot use the terminated sessions

  it(`Mallory tries to reply to and agree with himself (in brC)`, async () => {
    await mallory_brC.topic.clickReplyToOrigPost();
  });
  it(`... but there's a not-logged-in error`, async () => {
    await mallory_brC.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... after page refresh ...`, async () => {
    await mallory_brC.refresh2();
  });
  it(`... he sees he's logged out, sees the login button`, async () => {
    await mallory_brC.me.waitUntilKnowsNotLoggedIn();
    await mallory_brC.topbar.waitUntilLoginButtonVisible();  // ttt
  });

  it(`He's logged out in the other browser, brD, too`, async () => {
    await mallory_brD.refresh2();
    await mallory_brD.me.waitUntilKnowsNotLoggedIn();
  });


  // ----- Other users not affected

  it(`Maja is still logged in: She can submit a topic`, async () => {
    await maja_brB.complex.createAndSaveTopic({ title: "Important about cream",
            body: "Is milk cream the healthiest dinner, with or without sugar" });
  });

  it(`... and after reload ...`, async () => {
    await maja_brB.refresh2();
  });

  it(`... she's still logged in — sees her username menu`, async () => {
    assert.eq(await maja_brB.topbar.getMyUsername(), maja.username);
  });


  // ----- Mods cannot terminate admins' sessions

  it(`Owen logs in`, async () => {
    await maja_brB.topbar.clickLogout();
    await owen_brB.complex.loginWithPasswordViaTopbar(owen);
  });

  it(`Mons goes to Owen's profile page, the Preferences tab`, async () => {
    await mons_brA.userProfilePage.preferences.goHere(owen.username);
  });

  it(`... sees the About tab to the left ...`, async () => {
    await mons_brA.userProfilePage.preferences.tabs.waitForAboutTabDisplayed();
  });
  it(`... but there's no Security tab! Cannot view admin's sessions  UNIMPL TESTS_MISSING TyTESESTERMADM`, async () => {
    // hmm there is, but it won't work.
    //assert.not(await mons_brA.userProfilePage.preferences.tabs.isSecurityTabDisplayed());
  });

  it(`Mons instead types the URL path to Owen's Security tab`, async () => {
    await mons_brA.userProfilePage.preferences.security.goHere(owen);
  });
  it(`... there's an error: he's not allowed to view an admin's sessions`, async () => {
    await mons_brA.serverErrorDialog.waitAndAssertTextMatches('TyEADMINSESS_');
    await mons_brA.serverErrorDialog.close();
  });


  // ----- Admins can terminate mods' sessions

  it(`Owen jumps to Mon's Security tab`, async () => {
    await owen_brB.userProfilePage.preferences.security.goHere(mons);
  });

  it(`... logs Mons out`, async () => {
    await owen_brB.userProfilePage.preferences.security.terminateAllSessions();
  });

  it(`... now there's 1 terminated session`, async () => {
    await owen_brB.userProfilePage.preferences.security.waitForSessions({
            numActive: 0, numEnded: 1 });
  });


  // ----- Session won't work afterwards

  it(`Mons tries to view invites Owen has sent`, async () => {
    await mons_brA.userProfilePage.switchToInvites({ willFail: true });
  });
  it(`... but there's a not-logged-in error`, async () => {
    await mons_brA.serverErrorDialog.waitForNotLoggedInError();
  });
  it(`... Mons reloads the page ...`, async () => {
    await mons_brA.refresh2();
  });
  it(`... Mons sees he's logged out, sees the login button`, async () => {
    await mons_brA.me.waitUntilKnowsNotLoggedIn();
    await mons_brA.topbar.waitUntilLoginButtonVisible();  // ttt
  });
  

});

      it(`Owen reloads the page, as per the instructions ...`, async () => {
      });

      it(`... needs to log in again — sessions_t curently not exported`, async () => {
        await owensBrowser.loginDialog.loginWithPassword(owen);
      });

      it(`... he's Owen again`, async () => {
/// <reference path="../test-types.ts"/>

import * as _ from 'lodash';
import assert from '../utils/ty-assert';
import * as fs from 'fs';
import server from '../utils/server';
import * as utils from '../utils/utils';
import { buildSite } from '../utils/site-builder';
import { TyE2eTestBrowser } from '../utils/ty-e2e-test-browser';
import { logMessage, j2s, logBoring } from '../utils/log-and-die';
import c from '../test-constants';
import { IsWhere } from '../test-types';

let brA: TyE2eTestBrowser;
let brB: TyE2eTestBrowser;
let brC: TyE2eTestBrowser;
let owen: Member;
let owen_brA: TyE2eTestBrowser;
let alice: Member;
let alice_brC: TyE2eTestBrowser;
let mons: Member;
let mons_brA: TyE2eTestBrowser;
let maja: Member;
let maja_brA: TyE2eTestBrowser;
let mallory: Member;
let mallory_brB: TyE2eTestBrowser;

// Try to remember to update, if adding more users?
const majasUserId = 104;

const weakSidErrCode = 'TyEWEAKSID_';  // move to test constants?

const localHostname = 'comments-for-e2e-test-stealembs-localhost-8080';
const embeddingOrigin = 'http://e2e-test-stealembs.localhost:8080';
let embeddingPageSlug = 'emb-sess-stealer.html';
let embeddingPageUrl = embeddingOrigin + '/' + embeddingPageSlug;

let site: IdAddress;
let forum: TwoCatsTestForum;

let monsLocalStorageSessionJsonSt = '';

let stolenSidPart1And2WasMons = '';

let stolenSidPart1OnlyWasOwens = '';
let stolenSidPart1And2WasOwens = '';


describe(`embcom.sessions-emb-sess-cannot-moderate.3br  TyTEECSESS0MOD`, () => {

  it(`Construct site`, async () => {
    const builder = buildSite();
    forum = builder.addTwoCatsForum({
      title: "Emb Comments Sessions Cannot Moderate",
      members: ['owen', 'alice', 'mons', 'maja', 'mallory'],
    });

    builder.getSite().meta.localHostname = localHostname;
    builder.getSite().settings.allowEmbeddingFrom = embeddingOrigin;

    brA = new TyE2eTestBrowser(wdioBrowserA, 'brA');
    brB = new TyE2eTestBrowser(wdioBrowserB, 'brB');
    brC = new TyE2eTestBrowser(wdioBrowserC, 'brC');

    owen = forum.members.owen;
    owen_brA = brA;

    mons = forum.members.mons;
    mons_brA = brA;

    maja = forum.members.maja;
    maja_brA = brA;

    mallory = forum.members.mallory;
    mallory_brB = brB;

    alice = forum.members.alice;
    alice_brC = brC;

    assert.refEq(builder.getSite(), forum.siteData);
  });

  it(`Import site`, async () => {
    site = server.importSiteData(forum.siteData);
    server.skipRateLimits(site.id);
  });


  it(`Create embedding pages`, () => {
    const dir = 'target';
    fs.writeFileSync(`${dir}/${embeddingPageSlug}`, makeHtml('aaa', '#800'));
    function makeHtml(pageName: St, bgColor: St): St {
      return utils.makeEmbeddedCommentsHtml({
              pageName, discussionId: '', localHostname, bgColor});
    }
  });


  it(`Mons goes to a dangerous embedding page (in browser A)
            with an xss attack that lets Mallory steal session part 1 and 2`, async () => {
    await mons_brA.go2(embeddingPageUrl);
  });

  it(`... Mons logs in`, async () => {
    await mons_brA.complex.loginIfNeededViaMetabar(mons);
  });

  it(`Mallory steals the session via the xss attack`, async () => {
    monsLocalStorageSessionJsonSt = await mons_brA.execute(function() {
      return localStorage.getItem('talkyardSession');
    });

    stolenSidPart1And2WasMons = JSON.parse(monsLocalStorageSessionJsonSt).weakSessionId;

    logMessage(`Mallory stole Mons' session: "${monsLocalStorageSessionJsonSt}",
        part 1 + 2: "${stolenSidPart1And2WasMons}"`);
  });


  it(`Mallory goes to the embedding page, in his own browser B`, async () => {
    await mallory_brB.go2(embeddingPageUrl);
  });

  it(`... puts the stolen session in localStorage`, async () => {
    await mallory_brB.execute(function(s) {
      localStorage.setItem('talkyardSession', s);
    }, monsLocalStorageSessionJsonSt);
  });

  it(`... reloads`, async () => {
    await mallory_brB.refresh2();
  });

  it(`... now Mallory is logged in as Mons — but with session id parts 1+2 only`, async () => {
    await mallory_brB.switchToEmbeddedCommentsIrame();
    assert.eq(await mallory_brB.metabar.getMyUsernameInclAt(), '@' + mons.username);
  });

  it(`Bad 1, will work: Mallory posts a comment`, async () => {
    await mallory_brB.complex.replyToEmbeddingBlogPost(
            "Hi I'm Mons no I'm not yes_no_yes_you_guess");
  });

  let resp;

  it(`Bad 2, won't work: Mallory tries to suspend Maja`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded(); // reidr
    resp = await mallory_brB.hackServer.suspendUser(majasUserId);
  });
  it(`Bad 2: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 2: ... there's an error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });

  it(`Bad 3: Mallory then tries to add his own email addr to Mon's account`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mallory_brB.hackServer.addEmailAddresses("mallory@x.co");
  });
  it(`Bad 3: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 3: ... there's an error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });


  it(`Bad 4: Mallory tries to list Mons' email addresses`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mallory_brB.hackServer.loadEmailAddressesAndLoginMethods();
  });
  it(`Bad 4: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 4: ... there's another error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });


  it(`Good 4: Mons also cannot list his email addresses from inside the iframe`, async () => {
    await mons_brA.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mons_brA.hackServer.loadEmailAddressesAndLoginMethods();
  });
  it(`Good 4: ... the server says no to Mons too`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Good 4: ... Mons also gets an error dialog`, async () => {
    await mons_brA.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mons_brA.serverErrorDialog.close();
  });


  /*
  it(`Bad 5: Mallory tries to download Mons' personal data  TESTS_MISSING`, async () => {
    await mallory_brB.switchToEmbCommentsIframeIfNeeded();  // reidr
    resp = await mallory_brB.hackServer.  ?? ();
  });
  it(`Bad 5: ... the server refuses`, async () => {
    assert.not(resp.serverSaysOk);  // ttt
    assert.that(resp.serverSaysError);
  });
  it(`Bad 5: ... there's another error dialog`, async () => {
    await mallory_brB.serverErrorDialog.waitAndAssertTextMatches(weakSidErrCode);
    await mallory_brB.serverErrorDialog.close();
  });
  */

  let monsUserId: Nr | U;

  it(`Mallory remembers Mons' user id`, async () => {
    monsUserId = await mallory_brB.hackServer.getMyUserId();
    logBoring(`Mons' user id is: ${monsUserId}`);
  });



  it(`Mallory click-opens Mons' profile in a new tab`, async () => {
    await mallory_brB.metabar.openMyProfilePageInNewTab();
  });

  it(`... but in the new tab, he's not logged in — cookies missing`, async () => {
    await mallory_brB.swithToOtherTabOrWindow();
    await mallory_brB.me.waitUntilKnowsNotLoggedIn();
  });


  it(`Mons, though, can click his name ...`, async () => {
    await mons_brA.switchToEmbCommentsIframeIfNeeded();
    await mons_brA.metabar.openMyProfilePageInNewTab();
  });

  it(`... and in the new tab, he *is* logged in`, async () => {
    await mons_brA.swithToOtherTabOrWindow();
    await mons_brA.me.waitUntilLoggedIn();
    await mons_brA.topbar.assertMyUsernameMatches(mons.username);
  });

  it(`Mons checks his email addresses`, async () => {
    await mons_brA.userProfilePage.tabs.switchToPreferences();
    await mons_brA.userProfilePage.preferences.tabs.switchToAccount();
  });

  it(`... no new address has been added`, async () => {
    const adrs: St[] =
            await mons_brA.userProfilePage.preferences.emailsLogins.getAllEmailAddresses();
    assert.deepEq(adrs, [mons.emailAddress]);
  });


  it(`Mallory uses the stolen session parts 1+2, in the new tab`, async () => {
    await mallory_brB.hackServer.setSidPart12ViaJs(stolenSidPart1And2WasMons, { inCookie: true });
  });

  it(`... now Mallory has a cookie for session parts 1+2`, async () => {
    const sids = await mallory_brB.hackServer.getSids();
    logMessage(`getSids() says: ${j2s(sids)}`);
    assert.greaterThan(sids.sidCookiePart123.length, 30);
  });

  it(`Mallory reloads, tries to use the parts 1+2 cookie`, async () => {
    await mallory_brB.refresh2({ isWhere: IsWhere.External });
  });
  it(`... but there's an error: SID part 3 missing`, async () => {
    const source = await mallory_brB.getPageSource();
    assert.includes(source, weakSidErrCode);
  });


  let monsDataUrl: St | U;
  let monsContentUrl: St | U;

  it(`Mallory constructs links to Mons' personal data and Mons' posts`, async () => {
    monsDataUrl = mallory_brB.user.genDownloadPersonalDataUrl(monsUserId);
    monsContentUrl = mallory_brB.user.genDownloadPersonalContentUrl(monsUserId);
  });

  it(`Mallory then tries to fetch Mons' personal data`, async () => {
    await mallory_brB.go2(monsDataUrl, { willBeWhere: IsWhere.External });
  });
  it(`... this not allowed — session id part 3 missing`, async () => {
    const source = await mallory_brB.getPageSource();
    assert.includes(source, weakSidErrCode);
  });

  it(`Mons, though, can download his data — he has SID part 3 HttpOnly`, async () => {
    await mons_brA.go2(monsDataUrl, { willBeWhere: IsWhere.External });
  });
  it(`... his name and emai (among other things) are in the JSON`, async () => {
    const source = await mons_brA.getPageSource();
    assert.includes(source, mons.username);
    assert.includes(source, mons.emailAddress);
    assert.excludes(source, weakSidErrCode);
  });


  it(`Mallory tries to fetch Mon's posts`, async () => {
    await mallory_brB.go2(monsContentUrl, { willBeWhere: IsWhere.External });
  });
  it(`... also not allowed — session id part 3 missing`, async () => {
    const source = await mallory_brB.getPageSource();
    assert.includes(source, weakSidErrCode);
  });

  it(`Mons can download his posts — he has SID part 3 HttpOnly`, async () => {
    await mons_brA.go2(monsContentUrl, { willBeWhere: IsWhere.External });
  });
  it(`... Mallory's post, made with the stolen SID part 1+2, is in the JSON`, async () => {
    const source = await mons_brA.getPageSource();
    assert.includes(source, 'yes_no_yes_you_guess');
    assert.excludes(source, weakSidErrCode);
  });


  /* Maybe?
  it(`... but when Mallory tries to use the sid, the server clears all session cookies
            — it notices that part 3, HttpOnly, is missing`, async () => {
  });

  it(`The server also ends the session — since something is amiss`, async () => {
  });
  */

});

  const wdioBrowserD: WebdriverIOAsync.MultiRemoteBrowser | U;
  EmbCommentsIframe = 4,  // hmm could add an enum value for EmbSessionIframe
export type ServerSays = ServerSaysOk | ServerSaysError;

export interface ServerSaysOk {
  serverSaysOk: A;
}

export interface  ServerSaysError {
  serverSaysError: A;
}

if (args['4br'] || args['4browsers']) {
  settings.numBrowsers = 4;
}
if (args['5br'] || args['5browsers'] || args['6br'] || args['6browsers']) {
  die(`E2e tests with 5 or 6 browsers isn't supported. [TyE2MNYBROWSRS]`);
}
import { ServerSays } from '../test-types2';
interface SidFieldsParts {
  sidFieldParts12Maybe3?: St;
}

interface SidParts extends SidFieldsParts {
  sidCookiePart123?: St;
  sidCookiePart4?: St;
  sidCookiePart5?: St;
}

  constructor(aWdioBrowser: WebdriverIOAsync.Browser,
          name: 'brA' | 'brB' | 'brC' | 'brD' | 'brAll') {
    async executeAsync<T>(script: ((...args: any[]) => V), ...args: any[]): Pr<T> {
    user = {
      genDownloadPersonalDataUrl: (userId: PatId): St => {
        return '/-/download-personal-data?userId=' + userId;
      },

      genDownloadPersonalContentUrl: (userId: PatId): St => {
        return '/-/download-my-content?authorId=' + userId;
      },
    }

            waitForContent?: false, discId?: St, theresOnlyOne?: true,
            theSessionIdIframe?: true } = {}) {
      if (ps.theSessionIdIframe) {
        commentsIframeSelector = 'iframe#talkyard-session';
      }
      else if (this.#useCommentsIframe?.discussionId) {
    async waitAndClickLast(selector: St, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {
      return await this.waitAndClickNth(selector, -1, ps);
    async waitAndClickNth(selector: St, n: Nr, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {   // BUG will only scroll the 1st elem into view [05YKTDTH4]
      dieIf(n === 0, "n starts on 1, change from 0 to 1 please (or -1 to click the last item)");
      logWarningIf(n !== 1 && ps.needScroll !== false,
      tyAssert.ok(elems.length >= Math.abs(n),
            `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
      switchToInvites: async (ps: { willFail?: true } = {}) => {
        if (!ps.willFail) {
          await this.invitedUsersList.waitUntilLoaded();
        }
      tabs: {
        switchToDraftsEtc: async (ps: { willFail?: true } = {}) => {
          await this.waitAndClick('.e_UP_DrftsB');
          if (!ps.willFail) {
            await this.waitUntilLoadingOverlayGone();
            await this.userProfilePage.draftsEtc.waitUntilLoaded();
          }
        },

        isPreferencesTabDisplayed: async (): Pr<Bo> => {
          return await this.isDisplayed('#e2eUP_PrefsB');
        },
        switchToPreferences: async () => {
          await this.userProfilePage.clickGoToPreferences();
        },
      },

        tabs: {
          waitForAboutTabDisplayed: async (): Pr<Bo> => {
            return await this.waitForDisplayed('.s_UP_Prf_Nav_AbtL');
          },
          switchToAbout: async (ps: { willFail?: true } = {}) => {
            await this.waitAndClick('.s_UP_Prf_Nav_AbtL');
            if (!ps.willFail) {
              await this.waitForVisible('.e_UP_Prefs_FN');
            }
          },

          switchToAccount: async (ps: { /*willFail?: true*/ } = {}) => {
            await this.userProfilePage.preferences.switchToEmailsLogins();
          },

          isSecurityTabDisplayed: async (): Pr<Bo> => {
            return await this.isDisplayed('.e_UP_Prf_Nav_SecL');
          },
          switchToSecurity: async () => {
            await this.waitAndClick('.e_UP_Prf_Nav_SecL');
            await this.userProfilePage.preferences.security.waitForLoaded();
          },
        },

        switchToEmailsLogins: async () => {  // MOVE to tabs.switchToAccount() instead (above)
          // Break out fn:  waitForLoaded();  ?
        security: {
          goHere: async (viewWho: St | Member, ps: { isGroup?: true, origin?: St,
                    loginAs?: Member } = {}) => {
            const username = _.isString(viewWho) ? viewWho : viewWho.username;
            await this.userProfilePage._goHere(username, ps, '/preferences/security');
            if (ps.loginAs) {
              await this.complex.loginWithPasswordViaTopbar(ps.loginAs);
            }
          },

          waitForLoaded: async (): Pr<V> => {
            await this.waitForVisible('.c_SessL');  // or waitForExist? works if not logged in
          },

          waitForSessions: async (ps: { numActive: Nr, numEnded: Nr }): Pr<V> => {
            let numNow;
            await this.waitUntil(async () => {
              numNow = await this.userProfilePage.preferences.security.countSessions();
              return _.isEqual(numNow, ps);
            }, {
              message: () => `Waiting for ${j2s(ps)} sessions, currently: ${j2s(numNow)}`,
            })
          },

          countSessions: async (): Pr<{ numActive: Nr, numEnded: Nr }> => {
            const numEnded = await this.count('.c_SessL_Sess-Ended');
            const numActive = await this.count('.c_SessL_Sess-Active');
            return { numEnded, numActive };
          },

          numEndSessionButtonsVisible: async (): Pr<Nr> => {
            const num = await this.count('.c_SessL_Sess_EndB');
            return num + await this.count('.c_SessL_EndAllB');
          },

          terminateNewestSession: async (): Pr<V> => {
            await this.waitAndClickFirst('.c_SessL_Sess_EndB');
          },

          terminateOldestSession: async (): Pr<V> => {
            await this.scrollToBottom();
            await this.waitAndClickLast('.c_SessL_Sess_EndB', { needScroll: false });
          },

          terminateAllSessions: async (): Pr<V> => {
            await this.waitAndClick('.c_SessL_EndAllB');
          },
        },

          getAllEmailAddresses: async (): Pr<St[]> => {
            await this.waitForDisplayed('.s_UP_EmLg_EmL_It_Em');
            const elms = await this.$$('.s_UP_EmLg_EmL_It_Em');
            const adrs = [];
            for (const el of elms) {
              const text = await el.getText();
              adrs.push(text);
            }
            return adrs;
          },



  /// For hacking into Talkyard, doing weird things.
  ///
  hackServer = {
    setSidPart12ViaJs: async (value: St, ps: { inCookie?: true, inField?: true }): Pr<V> => {
      await this.execute(function(value, ps) {
        if (ps.inCookie) {
          console.debug(`Setting cookie TyCoSid123 = "${value}" ...`);
          window['getSetCookie']('TyCoSid123', value);
          console.debug(`Done.`);
        }
        if (ps.inField) {
          try {
            console.debug(`Setting getMainWin().typs.weakSessionId = "${value}" ...`);
            window['debiki2'].getMainWin().typs.weakSessionId = value;
            console.debug(`Done.`);
          }
          catch (ex) {
            console.error(ex);  // logs nothing?
          }
        }
      }, value, ps);
    },


    getSidPart12Maybe3ViaJs: async (ps: { inCookie?: true, inField?: true })
          : Pr<SidFieldsParts | U> => {
      dieIf(!!ps.inCookie == !!ps.inField, "TyE50MEPW24");
      const anyValue = await this.#br.execute(function(ps) {
        let result: SidFieldsParts | U;
        if (ps.inCookie) {
          die(`Unimpl: Getting cookie TyCoSid123...`);
          result = {
            // sidCookiePart123ViaJs: window['getSetCookie']('TyCoSid123'),
          };
          console.debug(`Done.`);
        }
        if (ps.inField) {
          try {
            console.debug(`Getting getMainWin().typs.weakSessionId...`);
            result = {
              sidFieldParts12Maybe3: window['debiki2'].getMainWin().typs.weakSessionId,
            };
            console.debug(`Done.`);
          }
          catch (ex) {
            console.error(ex);  // logs nothing?
          }
        }
        console.debug(`Returning: "${result}`);
        return result;
      }, ps);
      return anyValue;
    },

    getSids: async (): Pr<SidParts> => {
      const result: SidParts = await this.hackServer.getSidPart12Maybe3ViaJs({ inField: true });
      const sidCookies = await this.#br.getCookies(
              ['TyCoSid123', 'TyCoSid4', 'TyCoSid5']);
      for (const cookie of sidCookies) {
        const resultFieldName =
                cookie.name === 'TyCoSid123' ? 'sidCookiePart123' : (
                cookie.name === 'TyCoSid4' ? 'sidCookiePart4' : (
                cookie.name === 'TyCoSid5' ? 'sidCookiePart5' : die('TyE503MEGE2')));
        result[resultFieldName] = cookie.value;
      }
      return result;
    },

    getMyUserId: async (): Pr<PatId> => {
      return await this.execute(function() {
        const store = window['theStore'];
        return store.me.id;
      });
    },

    loadEmailAddressesAndLoginMethods: async (): Pr<ServerSays> => {
      const result: ServerSays = await this.executeAsync<ServerSays>(function(done) {
        const store = window['theStore'];
        const me = store.me;
        // Calls:  `/-/load-email-addrs-login-methods?userId=${userId}`
        window['debiki2'].Server.loadEmailAddressesAndLoginMethods(me.id, function(resp) {
          console.debug(`OK response: ${JSON.stringify(resp)}`);
          done({ serverSaysOk: resp });
        }, function(resp) {
          console.debug(`Error response: ${JSON.stringify(resp)}`);
          done({ serverSaysError: resp });
        });
      });
      console.debug(`Result: ${j2s(result)}`);
      return result;
    },

    addEmailAddresses: async (newAdr: St): Pr<ServerSays> => {
      const result: ServerSays = await this.executeAsync<ServerSays>(function(newAdr, done) {
        const store = window['theStore'];
        const me = store.me;
        // Calls:  /-/add-email-address
        // handled by:  UserController.addUserEmail().
        window['debiki2'].Server.addEmailAddresses(me.id, newAdr, function(resp) {
          console.debug(`OK response: ${JSON.stringify(resp)}`);
          done({ serverSaysOk: resp });
        }, function(resp) {
          console.debug(`Error response: ${JSON.stringify(resp)}`);
          done({ serverSaysError: resp });
        });
      }, newAdr);
      console.debug(`Result: ${j2s(result)}`);
      return result;
    },

    /* Currently not needed, maybe later?:

    tryChangeMyEmail: async (newAdr: St): Pr<{ emailAddresses } | { error }> => {
      const resp: A = await this.executeAsync(function(newAdr, done) {
        const store = window['theStore'];
        const me = store.me;
        // Calls: /-/set-primary-email-address
        // handled by: UserController.setPrimaryEmailAddresses().
        window['debiki2'].Server.setPrimaryEmailAddresses(me.id, newAdr, function(resp) {
          console.debug(`Emails after: ${JSON.stringify(resp)}`);
          done(resp);
        });
      }, newAdr);
      console.debug(`Emails after: ${JSON.stringify(resp)}`);
      return resp;
    },

    tryChangeMyPrefs: async (newAdr: St): Pr<V> => {
      const meAfter = await this.executeAsync(function(newAdr, done) {
        const store = window['theStore'];
        const me = store.me;
        window['debiki2'].Server.saveAboutPatPrefs({
          userId: me.id,
          emailAddress: newAdr,
          username: me.username,
          emailPref: 5, // ReceiveAlways  (whatever)
        }, false, // isGroup
        function(patAfter) { // onOk
          done(patAfter);
        });
      }, newAdr);
    }, */

    suspendUser: async (userId: PatId): Pr<ServerSays> => {
      const result: ServerSays = await this.executeAsync(function(userId, done) {
        // Calls:  /-/suspend-user
        // handled by:  UserController.suspendUser().
        window['debiki2'].Server.suspendUser(userId, 123, "No reason",
              function onOk(resp) {
                console.debug(`OK response: ${JSON.stringify(resp)}`);
                done({ serverSaysOk: resp });
              },
              function onErr(resp) {
                console.debug(`Error response: ${JSON.stringify(resp)}`);
                done({ serverSaysError: resp });
              });
      }, userId);
      console.debug(`Result: ${j2s(result)}`);
      return result;
    },
  };

    onlyAndSpec.indexOf('4br') >= 0 || settings.numBrowsers >= 4
        ? 4 : (
        ? 3 : (
    onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
        ? 2
        : 1));
  if (needsNumBrowsers >= 4) {
    config.capabilities.wdioBrowserD = {
      capabilities: { ...theCaps }
    };
  };

  // TESTS_MISSING: should not get any more live updates via WebSocket  TyTE0MOREWSMSG
  // Or is that better done in a separate e2e test file?

    // Deleting any session id cookie, logs us out.  Only cookie TyCoSid123
    // is not-HttpOnly and can be deleted, so let's delete it.
    // What! This is Webdriver.io, which can delete all cookies. But whatever,
    // let's delete 123 only, that's enough.
    owensBrowser.deleteCookie('TyCoSid123');  // TyTESESS123GONE
    Security: "Security",