CVE-2021-25981 (published 2022-01-03T07:15Z)

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 v2 base score: 10.0 (AV:N/AC:L/Au:N/C:C/I:C/A:C)
CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-613
cwe_name: Insufficient Session Expiration
description: According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/debiki/talkyard
commit_details: repo_url: https://github.com/debiki/talkyard
author: Kaj Magnus Lindberg
author_date: 2021-07-08 11:56:09+02:00
msg: Better session ids, 5 parts, feature flag to enable.
repository: repo_url: https://github.com/debiki/talkyard
repo_name: debiki/talkyard
description: A community discussion platform: Brings together the main features from StackOverflow, Slack, Discourse, Reddit, and Disqus blog comments.
repo_language: TypeScript
owner: debiki
file_changes: filename: Prelude.scala
change_type: ModificationType.MODIFY
diff: @@ -23,7 +23,7 @@ import java.{util => ju}
 import java.{security => js}
 import org.apache.commons.codec.{binary => acb}
 import org.scalactic.{ErrorMessage, Or}
-import play.api.libs.json.{JsNumber, JsObject, JsString}
+import play.api.libs.json._
 
 import scala.collection.mutable
 import scala.util.Try
@@ -64,13 +64,27 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   // Should get rid of this version:
   def warnDbgDie(errorMsg: String): Unit = { warnDbgDie("", errorMsg) }
 
-  def warnDbgDieIf(test: => Bo, errorCode: St, details: St): U = {
+  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 = {
     if (test) {
       warnDbgDie(errorCode, details)
     }
   }
 
+  def warnDevDieUnless(test: => Bo, errorCode: St, details: St = ""): U = {
+    if (!test) {
+      warnDbgDie(errorCode, details)
+    }
+  }
+
   def warnDbgDie(errorCode: St, warningMsg: St): U = {
+    warnDevDie(errorCode, warningMsg)
+  }
+
+  def warnDevDie(errorCode: St, warningMsg: St = ""): U = {
     if (true) {
       // Fail hard in debug mode so this error will be fixed.
       throw new AssertionError(s"$warningMsg [$errorCode]")
@@ -582,13 +596,34 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   }
 
 
-  /** 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).
+  /** 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).
     * Wikipedia says: "128-bit keys are commonly used and considered very strong".
     * Here: http://en.wikipedia.org/wiki/Key_(cryptography)
     */
-  def nextRandomString(): String =
-    new java.math.BigInteger(130, _random).toString(36) // 0..9, a..z
+  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
+    }
+  }
 
   // COULD split this in a random string function, and other id generation
   // functions, with string length adjusted, depending on how the random
@@ -648,12 +683,26 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   def saltAndHashEmail: St => St = saltAndHash(hashLengthEmail) _
   def saltAndHashIp: St => St = saltAndHash(hashLengthIp) _
 
-  SECURITY; COULD // use SHA-256 instead.
-  private def mdSha1 = js.MessageDigest.getInstance("SHA-1") // not thread safe
+  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 hashSha1Base64UrlSafe(text: String): String =
     acb.Base64.encodeBase64URLSafeString(mdSha1.digest(text.getBytes("UTF-8")))
 
+  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
+  }
+
 
   /* ------  Move to a 'security' package?    [406MRED256]
   def base32EncodeSecretKey(key: javax.crypto.SecretKey): St = {
@@ -789,25 +838,52 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   private val AToZUnderscoreRegex = "^[a-zA-Z_]*$".r
   private val VariableNameRegex = "^[a-zA-Z_][a-zA-Z0-9_]*$".r
   private val AlNumWithAl = "^[a-zA-Z0-9_]*[a-zA-Z_][a-zA-Z0-9_]*$".r
+  private val AlNumDashRegex = "^[a-zA-Z0-9_-]*$".r
 
   /** Checks that all fields names are okay variable names,
     * and that all values are numbers, or also okay variable names.
     * Just to avoid any unexpected things like some kind of injection.
     */
-  def anyWeirdJsObjField(obj: JsObject, maxLength: Int): Option[String] = {
+  def anyWeirdJsObjField(obj: JsObject, maxLength: i32,
+           allowHeaderNameValues: Bo = false): Opt[St] = {
+    unimplIf(allowHeaderNameValues, "TyE50MFEDJ4601")
     for ((fieldName, fieldValue) <- obj.fields) {
-      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 (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]")
+
       fieldValue match {
         case _: JsNumber =>
           // Fine
         case s: JsString =>
-          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")
+          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'")
+
         case _ =>
-          return Some(s"Value of field $fieldName is weird")
+          return Some(s"Value of field $fieldName is not a nummer or string; it is a ${
+            classNameOf(fieldValue)} [TyEFIELDVALTYP]")
       }
     }
     None
@@ -818,6 +894,52 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
   def anyWeirdJsObjField(obj: JsObject): Option[String] =
     anyWeirdJsObjField(obj, maxLength = 100)
 
+  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
+  } */
 
   /**
    * Pimps `String` with `matches(regex): Boolean` and `misses(regex)`
@@ -874,6 +996,9 @@ object Prelude {   CLEAN_UP; RENAME // to BugDie and re-export the interesting
     def isOkVariableName: Boolean =
       VariableNameRegex.pattern.matcher(underlying).matches
 
+    def isOkHeaderName: Bo =
+      underlying.nonEmpty && AlNumDashRegex.pattern.matcher(underlying).matches
+
     def isAlNum: Bo =
       underlying.forall(charIsAzOrNum)
 

programming_language: Scala
filename: Site.scala
change_type: ModificationType.MODIFY
diff: @@ -72,6 +72,34 @@ object Site {
 }
 
 
+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
+  }
+}
+
+
 /**
   * @param hostname — doesn't include any port number.
   */
@@ -81,8 +109,7 @@ case class SiteBrief(
   hostname: Opt[St],
   status: SiteStatus,
   featureFlags: St,
-) {
-  def isTestSite: Bo = id <= Site.MaxTestSiteId
+) extends SiteTrait {
 }
 
 
@@ -217,7 +244,7 @@ case class Site(  // Remove? Use SiteBrief or SiteDetailed instead?
   readLimitsMultiplier: Opt[f32],
   logLimitsMultiplier: Opt[f32],
   createLimitsMultiplier: Opt[f32],
-  ) extends SiteIdHostnames with SiteLimitsMultipliers {
+  ) extends SiteIdHostnames with SiteLimitsMultipliers with SiteTrait {
 
   // Reqiure at most 1 canonical host.
   //require((0 /: hosts)(_ + (if (_.isCanonical) 1 else 0)) <= 1)
@@ -227,8 +254,6 @@ case class Site(  // Remove? Use SiteBrief or SiteDetailed instead?
 
   def allHostnames: Seq[St] = hostnames.map(_.hostname)
 
-  def isTestSite: Bo = id <= MaxTestSiteId
-
   def brief: SiteBrief =
     SiteBrief(id, pubId, canonicalHostname.map(_.hostname), status,
           featureFlags = featureFlags)

programming_language: Scala
filename: SiteTransaction.scala
change_type: ModificationType.MODIFY
diff: @@ -450,6 +450,15 @@ trait SiteTransaction {   RENAME // to SiteTx — already started with a type Si
   def nextMemberId: UserId
   def insertMember(user: UserInclDetails): Unit
 
+
+  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 tryLoginAsMember(loginAttempt: MemberLoginAttempt, requireVerifiedEmail: Boolean)
         : Hopefully[MemberLoginGrant]
   def loginAsGuest(loginAttempt: GuestLoginAttempt): GuestLoginResult

programming_language: Scala
filename: SystemTransaction.scala
change_type: ModificationType.MODIFY
diff: @@ -109,6 +109,7 @@ trait SystemTransaction {  RENAME // to SysTx, started already
 
   // ----- The janitor: Old stuff deletion
 
+  def deletePersonalDataFromOldSessions(): U
   def deletePersonalDataFromOldAuditLogEntries(): Unit
   def deletePersonalDataFromOldSpamCheckTasks(): Unit
   def deleteOldUnusedUploads(): Unit = { /* ... later ... */ }

programming_language: Scala
filename: TySession.scala
change_type: ModificationType.ADD
diff: @@ -0,0 +1,234 @@
+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)
+
+}

programming_language: Scala
filename: package.scala
change_type: ModificationType.MODIFY
diff: @@ -99,6 +99,7 @@ package object core {
   def isDevOrTest: Bo = Prelude.isDevOrTest
   def isProd: Bo = Prelude.isProd
 
+  type SidSt = St   // [Scala_3] opaque type
   type SignOnId = St   // [Scala_3] opaque type   change to SsoId?
   type SsoId = SignOnId   // [Scala_3] opaque type   change to SsoId?
 
@@ -666,6 +667,8 @@ package object core {
   type ReqrId = Who // RENAME to ReqrIds? (with an ...s)
                     // ... because is more than one id (user id, ip, bowser id cookie, etc)
 
+  type BrowserIdSt = St  // [Scala_3] opaque type
+
   RENAME // to ReqrId? = "Requester id" and that's what it is: the user id plus hens browser id data.
   // I find "who" being confusing as to whom it refers to.
   case class Who(id: UserId, browserIdData: BrowserIdData) {
@@ -1617,5 +1620,6 @@ package object core {
 
   def GRAPH_DATABASE = () // Some queries are inefficient and require lots of code, when using a
                           // relational database — but are simple and fast, with a graph database.
+  def CHECK_AUTHN_STRENGTH = ()
 }
 

programming_language: Scala
filename: CreateSiteSystemDaoMixin.scala
change_type: ModificationType.MODIFY
diff: @@ -226,6 +226,7 @@ trait CreateSiteSystemDaoMixin extends SystemTransaction {  // RENAME to SystemS
       delete from usernames3 where site_id = ?
       delete from user_emails3 where site_id = ?
       delete from group_participants3 where site_id = ?
+      delete from sessions_t where site_id_c = ?
       delete from users3 where site_id = ?
       """).trim.split("\n").toBuffer
 

programming_language: Scala
filename: Rdb.scala
change_type: ModificationType.MODIFY
diff: @@ -117,6 +117,13 @@ object Rdb {
     def orNullJson: AnyRef = opt.getOrElse(Null(js.Types.OTHER))
   }
 
+  implicit class PimpJsObjWithNullIfEmpty(jsOb: JsObject) {
+    def orNullIfEmpty: AnyRef = {
+      if (jsOb.value.isEmpty) Null(js.Types.OTHER)
+      else jsOb
+    }
+  }
+
   /*
   implicit class PimpOptionWithNullArray(opt: Option[ ? ]) {
     def orNullArray: AnyRef = opt.getOrElse(Null(js.Types.ARRAY))
@@ -330,6 +337,16 @@ object Rdb {
     else Some(When.fromMillis(timestamp.getTime))
   }
 
+  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))
+  }
+
   def getOptArrayOfStrings(rs: js.ResultSet, column: String): Option[immutable.Seq[String]] = {
     val sqlArray: js.Array = rs.getArray(column)
     if (sqlArray eq null) return None

programming_language: Scala
filename: RdbSiteTransaction.scala
change_type: ModificationType.MODIFY
diff: @@ -50,6 +50,7 @@ class RdbSiteTransaction(var siteId: SiteId, val daoFactory: RdbDaoFactory, val
   with SearchSiteDaoMixin
   with SpamCheckQueueDaoMixin
   with AuthnSiteTxMixin
+  with SessionsRdbMixin
   with UserSiteDaoMixin
   with EmailAddressesSiteDaoMixin
   with UsernamesSiteDaoMixin

programming_language: Scala
filename: RdbSystemTransaction.scala
change_type: ModificationType.MODIFY
diff: @@ -895,6 +895,37 @@ class RdbSystemTransaction(
   val SomeMonthsAgo = 5
   val SomeYearsAgo = 5
 
+  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))
+    */
+  }
+
+
   def deletePersonalDataFromOldAuditLogEntries() {
     TESTS_MISSING
 
@@ -920,6 +951,7 @@ class RdbSystemTransaction(
         forgotten = 0 and
         done_at < ?
       """
+
     runUpdate(deleteABitStatement, List(now.minusMonths(SomeMonthsAgo).asTimestamp))
 
     PRIVACY; COULD // make x years below configurable
@@ -1049,6 +1081,7 @@ class RdbSystemTransaction(
       delete from usernames3
       delete from user_emails3
       delete from group_participants3
+      delete from sessions_t
       delete from users3
       delete from hosts3
       delete from sites3

programming_language: Scala
filename: SessionsRdbMixin.scala
change_type: ModificationType.ADD
diff: @@ -0,0 +1,234 @@
+/**
+ * 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"))
+  }
+
+}

programming_language: Scala
filename: DebugTestController.scala
change_type: ModificationType.MODIFY
diff: @@ -41,6 +41,7 @@ import scala.concurrent.Future._
 import scala.util.Try
 import talkyard.server.TyLogging
 import talkyard.server.JsX._
+import talkyard.server.authn.MinAuthnStrength
 
 
 /** Intended for troubleshooting, via the browser, and helps running End-to-End tests.
@@ -55,7 +56,8 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdConte
   /** If a JS error happens in the browser, it'll post the error message to this
     * endpoint, which logs it, so we'll get to know about client side errors.
     */
-  def logBrowserErrors: Action[JsValue] = PostJsonAction(RateLimits.BrowserError, maxBytes = 10000) {
+  def logBrowserErrors: Action[JsValue] = PostJsonAction(
+        RateLimits.BrowserError, MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 10000) {
         request =>
     val allErrorMessages = request.body.as[Seq[String]]
     // If there are super many errors, perhaps all of them is the same error. Don't log too many.
@@ -298,15 +300,16 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdConte
         request =>
     throwForbiddenIf(globals.isProd, "TyE502KUJ5",
         "I only do this, in Prod mode, when an odd number of " +
-          "Phoenix birds sleep at my fireplace")
+          "Phoenix birds sleep at my fireplace, and more than one")
     val key = (request.body \ "key").as[String]
     context.globals.redisClient.del(key)
     Ok
   }
 
 
-  def skipRateLimitsForThisSite: Action[JsValue] =
-        PostJsonAction(RateLimits.BrowserError, maxBytes = 150) { request =>
+  def skipRateLimitsForThisSite: Action[JsValue] = PostJsonAction(
+        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 150) {
+            request =>
     val okE2ePassword = context.security.hasOkE2eTestPassword(request.underlying)
     throwForbiddenIf(globals.isProd && !okE2ePassword,
       "TyE8WTHFJ25", "I only do this, in Prod mode, if I can see two moons from " +
@@ -332,8 +335,8 @@ class DebugTestController @Inject()(cc: ControllerComponents, edContext: EdConte
   }
 
 
-  def addAdminNotice: Action[JsValue] =
-        PostJsonAction(RateLimits.BrowserError, maxBytes = 50) { request =>
+  def addAdminNotice: Action[JsValue] = PostJsonAction(
+        RateLimits.BrowserError, MinAuthnStrength.E2eTestPassword, maxBytes = 50) { request =>
     val okE2ePassword = context.security.hasOkE2eTestPassword(request.underlying)
     throwForbiddenIf(globals.isProd && !okE2ePassword, "TyE60MRGP35", "E2e pwd missing")
     import request.body

programming_language: Scala
filename: DraftsController.scala
change_type: ModificationType.MODIFY
diff: @@ -27,9 +27,10 @@ import ed.server.http._
 import javax.inject.Inject
 import play.api.libs.json._
 import play.api.mvc._
-import scala.util.Try
 import scala.collection.immutable
 import talkyard.server.JsX.JsDraft
+import talkyard.server.authn.MinAuthnStrength
+
 
 
 class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
@@ -39,7 +40,8 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
   import context.security.{throwNoUnless, throwIndistinguishableNotFound}
 
 
-  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = MaxPostSize) {
+  def upsertDraft: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
         request: JsonPostRequest =>
     upsertDraftImpl(request.body, request)
   }
@@ -48,7 +50,8 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
   /** In the browser, navigator.sendBeacon insists on sending plain text. So need this text handler.
     */
   def upsertDraftBeacon: Action[String] = PostTextAction(
-        RateLimits.DraftSomething, maxBytes = MaxPostSize) { request =>
+        RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) { request =>
     val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD]
     val json = Json.parse(bodyXsrfTokenRemoved)
     upsertDraftImpl(json, request)
@@ -79,6 +82,8 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
     draft = draft.copy(
       byUserId = requester.id)  // [602KDGRE20]
 
+    CHECK_AUTHN_STRENGTH
+
     // Early access control, if possible:
     //
     if (draft.isNewTopic) {
@@ -184,13 +189,15 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething, maxBytes = 1000) {
+  def deleteDrafts: Action[JsValue] = PostJsonAction(RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {
         request: JsonPostRequest =>
     deleteDraftsImpl(request.body, request)
   }
 
 
-  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething, maxBytes = 1000) {
+  def deleteDraftsBeacon: Action[String] = PostTextAction(RateLimits.DraftSomething,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) {
         request: ApiRequest[String] =>
     val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD]
     val json = Json.parse(bodyXsrfTokenRemoved)
@@ -202,6 +209,8 @@ class DraftsController @Inject()(cc: ControllerComponents, edContext: EdContext)
     import request.{dao, theRequester => requester}
     val byUserId = requester.id
     val draftNrs = json.as[Seq[DraftNr]]
+
+    CHECK_AUTHN_STRENGTH
     dao.readWriteTransaction { tx =>
       draftNrs.foreach(nr => tx.deleteDraft(byUserId, nr))
     }

programming_language: Scala
filename: EditController.scala
change_type: ModificationType.MODIFY
diff: @@ -32,6 +32,7 @@ import EditController._
 import debiki.onebox.LinkPreviewRenderer
 import scala.concurrent.ExecutionContext
 import talkyard.server.JsX.{JsDraft, JsDraftOrNull, JsStringOrNull}
+import talkyard.server.authn.MinAuthnStrength
 import org.scalactic.{Good, Or, Bad}
 
 
@@ -47,8 +48,10 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
   REFACTOR // Move to DraftController?
   def loadDraftAndGuidelines(writingWhat: Int, draftType: Int, pageRole: Int,
         categoryId: Option[Int], toUserId: Option[UserId],
-        pageId: Option[String], postNr: Option[Int]): Action[Unit] =
-      GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
+        pageId: Option[String], postNr: Option[Int],
+        ): Action[Unit] =
+            GetActionRateLimited(
+              RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
 
     import request.{dao, requester}
 
@@ -80,7 +83,9 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
           postNr = postNr))
     }
 
-    val drafts = requester flatMap { theRequester =>
+    CHECK_AUTHN_STRENGTH
+
+    val drafts: ImmSeq[Draft] = requester flatMap { theRequester =>
       anyDraftLocator map { draftLocator =>
         dao.readOnlyTransaction { tx =>
           tx.loadDraftsByLocator(theRequester.id, draftLocator)
@@ -128,8 +133,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
   /** Sends back a post's current CommonMark source to the browser.
     * SHOULD change to pageId + postId (not postNr)  [idnotnr]
     */
-  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] =
-        GetActionRateLimited(RateLimits.ReadsFromDb) { request =>
+  def loadDraftAndText(pageId: String, postNr: Int): Action[Unit] = GetActionRateLimited(
+        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
 
     import request.{dao, theRequester => requester}
 
@@ -137,6 +142,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val post = dao.loadPost(pageId, postNr) getOrElse throwIndistinguishableNotFound("EdE0DK9WY3")
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayEditPost(
       request.theUserAndLevels, dao.getOnesGroupIds(request.theUser),
       post, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta),
@@ -169,7 +176,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
   /** Edits posts.
     */
-  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost, maxBytes = MaxPostSize) {
+  def edit: Action[JsValue] = PostJsonAction(RateLimits.EditPost,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
         request: JsonPostRequest =>
     import request.{dao, body}
     val pageId = (body \ "pageId").as[PageId]
@@ -200,6 +208,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayEditPost(
       request.theUserAndLevels, dao.getOnesGroupIds(request.theUser),
       post, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta),
@@ -232,7 +242,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     * then creates and returns sanitized onebox html.
     */
   def fetchLinkPreview(url: St, curPageId: PageId, inline: Bo): Action[U] =
-        AsyncGetActionRateLimited(RateLimits.FetchLinkPreview) { request =>
+        AsyncGetActionRateLimited(
+            RateLimits.FetchLinkPreview, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import edContext.globals
     import request.{siteId, requesterOrUnknown}
 
@@ -261,6 +272,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     // text and then later the server tries to render a link preview,
     // possibly as part of a rerender / reindex-things background job.
 
+    CHECK_AUTHN_STRENGTH
+
     val renderer = new LinkPreviewRenderer(
           globals, siteId = siteId, mayHttpFetch = true,
           requesterId = requesterOrUnknown.id)
@@ -291,7 +304,11 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   def loadPostRevisions(postId: PostId, revisionNr: String): Action[Unit] =
-        GetActionRateLimited(RateLimits.ExpensiveGetRequest) { request =>
+        GetActionRateLimited(RateLimits.ExpensiveGetRequest,
+            MinAuthnStrength.EmbeddingStorageSid12) { request =>
+
+    CHECK_AUTHN_STRENGTH
+
     val revisionNrInt =
       if (revisionNr == "LastRevision") PostRevision.LastRevisionMagicNr
       else revisionNr.toIntOption getOrElse throwBadRequest("EdE8UFMW2", "Bad revision nr")
@@ -327,7 +344,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost, maxBytes = 5000) { request =>
+  def deletePost: Action[JsValue] = PostJsonAction(RateLimits.DeletePost,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 5000) { request =>
     import request.dao
     val pageId = (request.body \ "pageId").as[PageId]
     val postNr = (request.body \ "postNr").as[PostNr]
@@ -337,6 +355,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
       if (repliesToo) PostStatusAction.DeleteTree
       else PostStatusAction.DeletePost(clearFlags = false)
 
+    CHECK_AUTHN_STRENGTH
+
     val result = dao.changePostStatus(postNr, pageId = pageId, action, userId = request.theUserId)
 
     OkSafeJson(Json.obj(
@@ -349,7 +369,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def movePost: Action[JsValue] = StaffPostJsonAction(maxBytes = 300) { request =>
+  def movePost: Action[JsValue] = StaffPostJsonAction(
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 300) { request =>
     val pageId = (request.body \ "pageId").as[PageId]   // apparently not used
     val postId = (request.body \ "postId").as[PostId]   // id not nr
     val newHost = (request.body \ "newHost").asOpt[String] // ignore for now though
@@ -357,6 +378,8 @@ class EditController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val newPageId = (request.body \ "newPageId").as[PageId]
     val newParentNr = (request.body \ "newParentNr").asOpt[PostNr].getOrElse(PageParts.BodyNr)
 
+    CHECK_AUTHN_STRENGTH
+
     val (_, storePatch) = request.dao.movePostIfAuth(PagePostId(pageId, postId),
       newParent = PagePostNr(newPageId, newParentNr), moverId = request.theMember.id,
       request.theBrowserIdData)

programming_language: Scala
filename: EmbeddedTopicsController.scala
change_type: ModificationType.MODIFY
diff: @@ -49,6 +49,13 @@ class EmbeddedTopicsController @Inject()(cc: ControllerComponents, edContext: Ed
   }
 
 
+  /** 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]
+    */
   def showTopic(embeddingUrl: String, discussionId: Option[AltPageId],   // [5BRW02]
           edPageId: Option[PageId], category: Option[Ref], scriptV: Opt[St]): Action[U] =
       AsyncGetActionMaybeSkipCookies(avoidCookies = true) { request =>
@@ -124,6 +131,16 @@ class EmbeddedTopicsController @Inject()(cc: ControllerComponents, edContext: Ed
           a specific id, you can use the data-discussion-id="..." html attribute in
           the Talkyard html code snippet in your blog.""")
 
+        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]
+
         val (maySee, debugCode) = dao.maySeePageUseCache(pageMeta, request.requester)
         if (!maySee)
           security.throwIndistinguishableNotFound(debugCode)
@@ -133,6 +150,7 @@ class EmbeddedTopicsController @Inject()(cc: ControllerComponents, edContext: Ed
 
         val pageRequest = new PageRequest[Unit](
           request.site,
+          request.anyTySession,
           sid = request.sid,
           xsrfToken = request.xsrfToken,
           browserId = request.browserId,

programming_language: Scala
filename: FlagController.scala
change_type: ModificationType.MODIFY
diff: @@ -25,6 +25,7 @@ import javax.inject.Inject
 import play.api.mvc._
 import ed.server.auth.Authz
 import play.api.libs.json.JsValue
+import talkyard.server.authn.MinAuthnStrength
 
 
 
@@ -36,7 +37,8 @@ class FlagController @Inject()(cc: ControllerComponents, edContext: EdContext)
   import context.security._
 
 
-  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost, maxBytes = 2000) { request =>
+  def flagPost: Action[JsValue] = PostJsonAction(RateLimits.FlagPost,
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2000) { request =>
     import request.{body, dao}
     SHOULD // change from page-id + post-nr to post-id.
     val pageId = (body \ "pageId").as[PageId]
@@ -57,6 +59,8 @@ class FlagController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val post = dao.loadPost(pageId, postNr) getOrElse throwIndistinguishableNotFound("EdE5PJB2R8")
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayFlagPost(
       request.theMember, dao.getOnesGroupIds(request.theUser),
       post, pageMeta, dao.getAnyPrivateGroupTalkMembers(pageMeta),

programming_language: Scala
filename: ImpersonateController.scala
change_type: ModificationType.MODIFY
diff: @@ -48,7 +48,7 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
   val MaxKeyAgeSeconds = 3600
 
   // This stuff is used client side too. [8AXFC0J2]
-  private val ImpersonationCookieName = "esCoImp"
+  private val ImpersonationCookieName = "esCoImp"  ; SECURITY ; SHOULD // add a HttpOnly cookie too? & delete. Both cookies required.
   private val FieldSeparator = '.'
   private val ViewAsGroupOnly = "VAO"
   private val ImpersonateRealUser = "IRU"
@@ -111,7 +111,7 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
       throwForbidden("EsE8YKW3", s"Wrong site id: ${request.siteId}, should go to site $siteId")
 
     // ? mark as online ?
-    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, userId)
+    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, userId)
     Redirect("/").withCookies(sidAndXsrfCookies: _*)
   }
 
@@ -144,11 +144,11 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
     dieIf(anyUserId.isDefined && viewAsOnly, "EdE6WKT0S")
 
     val sidAndXsrfCookies = anyUserId.toList flatMap { userId =>
-      createSessionIdAndXsrfToken(request.siteId, userId)._3
+      createSessionIdAndXsrfToken(request, userId)._3
     }
 
-    val logoutCookie =
-      if (anyUserId.isEmpty) Seq(DiscardingSessionCookie)
+    val logoutCookies =
+      if (anyUserId.isEmpty) DiscardingSessionCookies
       else Nil
 
     val impCookie = makeImpersonationCookie(request.siteId, viewAsOnly, request.theUserId)
@@ -160,7 +160,7 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
     // time events isn't the purpose of view-site-as.  The client should resubscribe
     // the requester to hens *own* notfs, once done impersonating, though.
 
-    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookie: _*)
+    Ok.withCookies(newCookies: _*).discardingCookies(logoutCookies: _*)
   }
 
 
@@ -182,19 +182,22 @@ class ImpersonateController @Inject()(cc: ControllerComponents, edContext: EdCon
         // What's this? Clicking Stop Impersonating, but no such cookie?
         // Maybe clicking twice in different tabs? Anyway, feels
         // better to log out, so as not to accidentally stay logged in somehow.
-        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
+        LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
+              wasImpersonating = true)
       case Some(cookieValue) =>
         val response =
           checkHashElseGetAgeAndUserId(cookieValue) match {
             case Bad(r) => r
             case Good((secondsAgo, oldUserId)) =>
-              // Ignore old impersonation cookies, in case they're leaked somehow.
+                // Ignore old impersonation cookies, in case they're leaked somehow.
                 if (secondsAgo > MaxBecomeOldUserSeconds || oldUserId == NoUserId) {
-                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None)
+                  LoginController.doLogout(request, redirectIfMayNotSeeUrlPath = None,
+                        wasImpersonating = true)
                 }
                 else {
                   // Restore the old user id.
-                  val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, oldUserId)
+                  val (_, _, sidAndXsrfCookies) =
+                        createSessionIdAndXsrfToken(request, oldUserId)
                   Ok.withCookies(sidAndXsrfCookies: _*)
                 }
           }

programming_language: Scala
filename: InviteController.scala
change_type: ModificationType.MODIFY
diff: @@ -254,7 +254,7 @@ class InviteController @Inject()(cc: ControllerComponents, edContext: EdContext)
       secretKey, request.theBrowserIdData)
 
     dao.pubSub.userIsActive(request.siteId, newUser.briefUser, request.theBrowserIdData)
-    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, newUser.id)
+    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, newUser.id)
     val newSessionCookies = sidAndXsrfCookies
 
     if (!alreadyAccepted) {

programming_language: Scala
filename: LoginAsGuestController.scala
change_type: ModificationType.MODIFY
diff: @@ -95,7 +95,7 @@ class LoginAsGuestController @Inject()(cc: ControllerComponents, edContext: EdCo
       val guestUser = dao.loginAsGuest(loginAttempt)
 
       val (sid, _, sidAndXsrfCookies) =
-        security.createSessionIdAndXsrfToken(request.siteId, guestUser.id)
+            security.createSessionIdAndXsrfToken(request, guestUser.id)
 
       var responseJson = Json.obj(  // ts: AuthnResponse
         "userCreatedAndLoggedIn" -> JsTrue,

programming_language: Scala
filename: LoginController.scala
change_type: ModificationType.MODIFY
diff: @@ -36,7 +36,7 @@ class LoginController @Inject()(cc: ControllerComponents, edContext: EdContext)
   extends EdController(cc, edContext) {
 
   import context.globals
-  import context.security.DiscardingSessionCookie
+  import context.security.DiscardingSessionCookies
   import LoginController._
 
 
@@ -122,20 +122,26 @@ class LoginController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  /** Clears login related cookies and OpenID and OpenAuth stuff, unsubscribes
-    * from any event channel.
+  /** Clears session cookies and ends the session server side too; unsubscribes
+    * from any websockets channel.
     */
   def logout(currentUrlPath: Opt[St]): Action[U] = GetActionAllowAnyone { request =>
-    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath)
+    SECURITY // optionally log out from all devices?
+    doLogout(request, redirectIfMayNotSeeUrlPath = currentUrlPath,
+          wasImpersonating = false)
   }
 
 
-  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St]): Result = {
+  def doLogout(request: GetRequest, redirectIfMayNotSeeUrlPath: Opt[St],
+          wasImpersonating: Bo): Result = {
     import request.{dao, requester, siteSettings}
 
+    AUDIT_LOG // session id destruction
+
     requester foreach { theRequester =>
-      request.dao.logout(theRequester, bumpLastSeen = true)
+      request.dao.logout(theRequester, bumpLastSeen = !wasImpersonating)
     }
+    dao.terminateSessionForCurReq(request.underlying)
 
     val goToNext: Opt[St] = siteSettings.effSsoLogoutAllRedirUrl orElse {
       redirectIfMayNotSeeUrlPath flatMap { urlPath =>
@@ -162,7 +168,7 @@ class LoginController @Inject()(cc: ControllerComponents, edContext: EdContext)
       }
 
     // Keep the xsrf cookie, so the login dialog will work.
-    response.discardingCookies(DiscardingSessionCookie)
+    response.discardingCookies(DiscardingSessionCookies: _*)
   }
 
 

programming_language: Scala
filename: LoginWithOpenAuthController.scala
change_type: ModificationType.MODIFY
diff: @@ -182,6 +182,7 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
   private val MayCreateUserCookieName = "dwCoMayCreateUser"
   private val AuthStateCookieName = "dwCoOAuth2State"
 
+  // Discard these also if logging in with username + password?  [clear_aun_cookies]
   private val CookiesToDiscardAfterLogin: Seq[DiscardingCookie] = Seq(
     ReturnToUrlCookieName,
     ReturnToSiteOriginTokenCookieName,
@@ -1592,6 +1593,7 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
     // missing. But supporting page reload here requires fairly many mini fixes,
     // and maybe is marginally worse for security? since then someone else,
     // e.g. an "evil" tech support person, can ask for and reuse the url?
+    // [.clearing_cookies] [clear_aun_cookies]
     result.discardingCookies(CookiesToDiscardAfterLogin: _*)
   }
 
@@ -1605,7 +1607,7 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
         authnState: OngoingAuthnState): Result = {
 
     request.dao.pubSub.userIsActive(request.siteId, member, request.theBrowserIdData)
-    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(siteId, member.id)
+    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, member.id)
 
     var maybeCannotUseCookies =
       request.headers.get(EdSecurity.AvoidCookiesHeaderName) is EdSecurity.Avoid
@@ -1948,6 +1950,8 @@ class LoginWithOpenAuthController @Inject()(cc: ControllerComponents, edContext:
         anyContinueToUrl = Some(authnState.returnToUrl)))
     }
 
+    // This not needed? These cookies, and many more, already cleared at the
+    // end of the caller, tryLoginOrShowCreateUserDialog()  [.clearing_cookies].
     result.discardingCookies(
       DiscardingSecureCookie(IsInLoginWindowCookieName),
       DiscardingSecureCookie(ReturnToUrlCookieName))

programming_language: Scala
filename: LoginWithPasswordController.scala
change_type: ModificationType.MODIFY
diff: @@ -125,7 +125,8 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
     }
 
     dao.pubSub.userIsActive(request.siteId, loginGrant.user, request.theBrowserIdData)
-    val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
+    val (sid, _, sidAndXsrfCookies) =
+          createSessionIdAndXsrfToken(request, loginGrant.user.id)
     (sid, sidAndXsrfCookies)
   }
 
@@ -138,7 +139,7 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
         allowAnyone = true) { request: JsonPostRequest =>
 
     // A bit dupl code. [2FKD05]
-    import request.body
+    import request.{body, dao}
 
     val fullName = (body \ "fullName").asOptStringNoneIfBlank
     val emailAddress = (body \ "email").as[String].trim
@@ -150,18 +151,6 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
     val maybeCannotUseCookies =
       request.headers.get(EdSecurity.AvoidCookiesHeaderName) is EdSecurity.Avoid
 
-    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 siteSettings = dao.getWholeSiteSettings()
 
     val itsDisabled = "Creation of local password accounts is disabled"
@@ -257,7 +246,8 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
         else {
           dieIf(newMember.email.isEmpty && requireVerifiedEmail, "EdE2GKF06")
           dao.pubSub.userIsActive(request.siteId, newMember, request.theBrowserIdData)
-          val (sid, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(dao.siteId, newMember.id)
+          val (sid, _, sidAndXsrfCookies) =
+                createSessionIdAndXsrfToken(request, newMember.id)
           (Some(sid), sidAndXsrfCookies)
         }
       }
@@ -290,6 +280,9 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
         "emailVerifiedAndLoggedIn" -> JsBoolean(emailVerifiedAt.isDefined),
         "weakSessionId" -> JsString(weakSessionId))
 
+      COULD_OPTIMIZE // Clear login related cookies? [clear_aun_cookies]
+      // Done already in the OAuth login controller; it clears these: CookiesToDiscardAfterLogin.
+
       OkSafeJson(responseJson).withCookies(loginCookies: _*)
     }
   }
@@ -352,7 +345,7 @@ class LoginWithPasswordController @Inject()(cc: ControllerComponents, edContext:
         (Nil, Some(returnToUrl + s"#talkyardOneTimeLoginSecret=$loginSecret"))
       }
       else {
-        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, user.id)
+        val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, user.id)
         val anyReturnToUrl: Option[String] =
           if (returnToUrl.nonEmpty) Some(returnToUrl) else None
         (sidAndXsrfCookies, anyReturnToUrl)

programming_language: Scala
filename: ReplyController.scala
change_type: ModificationType.MODIFY
diff: @@ -28,6 +28,7 @@ import javax.inject.Inject
 import play.api._
 import play.api.libs.json.{JsObject, JsString, JsValue, Json}
 import play.api.mvc._
+import talkyard.server.authn.MinAuthnStrength
 
 
 /** Saves replies. Lazily creates pages for embedded discussions
@@ -39,7 +40,8 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: EdContext)
   import context.security.{throwNoUnless, throwIndistinguishableNotFound}
 
 
-  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply, maxBytes = MaxPostSize) {
+  def handleReply: Action[JsValue] = PostJsonAction(RateLimits.PostReply,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = MaxPostSize) {
         request: JsonPostRequest =>
     import request.{body, dao, theRequester => requester}
     val anyPageId = (body \ "pageId").asOpt[PageId]
@@ -70,6 +72,8 @@ class ReplyController @Inject()(cc: ControllerComponents, edContext: EdContext)
     }
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.mayPostReply(
       request.theUserAndLevels, dao.getOnesGroupIds(request.theUser),
       postType, pageMeta, replyToPosts, dao.getAnyPrivateGroupTalkMembers(pageMeta),

programming_language: Scala
filename: ResetPasswordController.scala
change_type: ModificationType.MODIFY
diff: @@ -213,7 +213,7 @@ class ResetPasswordController @Inject()(cc: ControllerComponents, edContext: EdC
 
     // Log the user in and show password changed message.
     request.dao.pubSub.userIsActive(request.siteId, loginGrant.user, request.theBrowserIdData)
-    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request.siteId, loginGrant.user.id)
+    val (_, _, sidAndXsrfCookies) = createSessionIdAndXsrfToken(request, loginGrant.user.id)
     val newSessionCookies = sidAndXsrfCookies
     CSP_MISSING
     Ok(views.html.resetpassword.passwordHasBeenChanged(SiteTpi(request)))

programming_language: Scala
filename: UserController.scala
change_type: ModificationType.MODIFY
diff: @@ -37,7 +37,7 @@ import javax.inject.Inject
 import org.scalactic.{Bad, Good}
 import talkyard.server.JsX
 import talkyard.server.JsX._
-import talkyard.server.p_Result
+import talkyard.server.authn.MinAuthnStrength
 import talkyard.server.TyLogging
 
 
@@ -104,7 +104,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
   /** Loads a member or group, incl details, or a guest (then there are no details).
     */
-  def loadUserAnyDetails(who: String): Action[Unit] = GetAction { request =>
+  def loadUserAnyDetails(who: St): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.{dao, requesterOrUnknown}
     // First try looking up by `who` as a  numeric user id. If won't work,
     // lookup by `who` as username instead.
@@ -130,6 +131,9 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
     val tagTypes: Seq[TagType] = dao.getTagTypesForTags(tags)
 
+    // Maybe incl less data if embedded?
+    CHECK_AUTHN_STRENGTH
+
     // Maybe? No, stats is ok to show? Could possibly add another conf val, hmm.
     /*val stats =
       if (maySeeActivity(userId, request.requester, request.dao)) anyStatsJson
@@ -369,7 +373,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
   def downloadPersonalData(userId: UserId): Action[Unit] = GetActionRateLimited(
         RateLimits.DownloaPersonalData) { request: GetRequest =>
-      import request.{dao, theRequester => requester}
+    import request.{dao, theRequester => requester}
     throwForbiddenIf(userId != requester.id && !requester.isAdmin,
       "TyE2PKAQX8", "Cannot download someone else's data")
 
@@ -395,6 +399,10 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
         tx.loadUserEmailAddresses(userId).filterNot(_.emailAddress == member.primaryEmailAddress)
       val otherEmailsJson = JsArray(otherEmailAddresses.map(ea => JsString(ea.emailAddress)))
 
+      // To do: Incl old expired sessions too? Or maybe that should be a separate
+      // endpoint. [gdpr]
+      val sessions = dao.listPatsSessions(userId)
+
       val identities: Seq[Identity] = tx.loadIdentities(userId)
       val identitiesJson = JsArray(identities map {
         case oauthId: OpenAuthIdentity =>
@@ -442,7 +450,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
         "trustLevel" -> JsString(member.effectiveTrustLevel.toString),
         "identities" -> identitiesJson,
         "statistics" -> statsJson,
-        "browserIdDataRecentFirst" -> browserIdDataJson)
+        "browserIdDataRecentFirst" -> browserIdDataJson,
+        "sessions" -> sessions.map(s => JsSession(s, inclPart1 = false)))
     }
 
     OkSafeJson(result)
@@ -890,7 +899,11 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
   /** If not staff, returns a summary only.
     */
-  def loadAuthorBlocks(postId: Int): Action[Unit] = GetAction { request =>
+  def loadAuthorBlocks(postId: Int): Action[Unit] = GetActionRateLimited(
+        RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
+
+    CHECK_AUTHN_STRENGTH
+
     val blocks: Seq[Block] = request.dao.loadAuthorBlocks(postId)
     var json = blocksSummaryJson(blocks, request.ctime)
     if (request.user.exists(_.isStaff)) {
@@ -954,7 +967,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def loadMyPageData(pageIds: St): Action[U] = GetAction { request =>
+  def loadMyPageData(pageIds: St): Action[U] = GetAction2(RateLimits.ReadsFromDb,
+        MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.dao
     QUICK; COULD_OPTIMIZE // don't use String.split('') — it sometimes creates a regex.
     // Review the whole code base. // Use Guava's Splitter instead.
@@ -983,6 +997,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
       return dao.jsonMaker.userNoPageToJson(request)
     }
 
+    CHECK_AUTHN_STRENGTH
+
     val pagePath = request.dao.getPagePath(pageId) getOrElse {
       // The page was apparently deleted some microseconds ago.
       return dao.jsonMaker.userNoPageToJson(request)
@@ -995,6 +1011,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val pageRequest = new PageRequest(
       request.site,
       sid = request.sid,
+      anyTySession = request.anyTySession,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
       user = request.user,
@@ -1024,7 +1041,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   def trackReadingProgress: Action[JsValue] = PostJsonAction(RateLimits.TrackReadingActivity,
-        maxBytes = 1000) { request =>
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
     import request.{dao, theRequester}
     val readMoreResult = trackReadingProgressImpl(request, request.body)
     val result =
@@ -1033,6 +1050,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
         // Posts related to some notifications were seen. Update the notifications, client side,
         // so they'll get un-highlighted, since the posts they are about, have now been seen.
 
+        CHECK_AUTHN_STRENGTH // Skip the notifications, if in embedded iframe?
+
         // dupl line [8AKBR0]
         val notfsAndCounts = dao.loadNotificationsSkipReviewTasks(
           theRequester.id, upToWhen = None, request.who, unseenFirst = true, limit = 20)
@@ -1053,7 +1072,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   /** In the browser, navigator.sendBeacon insists on sending plain text. So need this text handler.
     */
   def trackReadingProgressText: Action[String] = PostTextAction(RateLimits.TrackReadingActivity,
-        maxBytes = 1000) { request =>
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 1000) { request =>
     val bodyXsrfTokenRemoved = request.body.dropWhile(_ != '\n') // [7GKW20TD]
     val json = Json.parse(bodyXsrfTokenRemoved)
     trackReadingProgressImpl(request, json)
@@ -1101,6 +1120,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
       throwForbidden("TyE5RKPW025", s"Bad post nr, smaller than BodyNr: $badNr")
     }
 
+    CHECK_AUTHN_STRENGTH // + may see?
+
     logger.trace(
       s"s$siteId, page $anyPageId: Post nrs read: $postNrsRead, seconds reading: $secondsReading")
 
@@ -1188,15 +1209,15 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
-        request =>
+  def markAllNotfsAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
+        maxBytes = 200) { request =>
     request.dao.markAllNotfsAsSeen(request.theUserId)
     loadNotificationsImpl(request.theUserId, upToWhen = None, request)
   }
 
 
-  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen, 200) {
-        request =>
+  def markNotificationAsSeen(): Action[JsValue] = PostJsonAction(RateLimits.MarkNotfAsSeen,
+        maxBytes = 200) { request =>
     import request.{dao, theRequesterId}
     val notfId = (request.body \ "notfId").as[NotificationId]
     dao.markNotificationAsSeen(theRequesterId, notfId)
@@ -1204,8 +1225,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def snoozeNotifications(): Action[JsValue] =
-          PostJsonAction(RateLimits.ConfigUser, 200) { request =>
+  def snoozeNotifications(): Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
+        maxBytes = 200) { request =>
     import request.{dao, theRequesterId}
     val untilWhen: Option[When] =
           (request.body \ "untilMins").as[JsValue] match {
@@ -1219,8 +1240,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser, maxBytes = 500) {
-        request =>
+  def saveContentNotfPref: Action[JsValue] = PostJsonAction(RateLimits.ConfigUser,
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) { request =>
     import request.{dao, theRequester => requester}
     val body = request.body
     val memberId = (body \ "memberId").as[MemberId]
@@ -1280,6 +1301,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
                 notfLevel = newNotfLevel.getOrElse(NotfLevel.DoesNotMatterHere)))
             .getOrIfFailure(ex => throwBadRequest("TyE2ABKRP0", ex.getMessage))
 
+    CHECK_AUTHN_STRENGTH
+
     if (newNotfLevel.isDefined) {
       dao.savePageNotfPrefIfAuZ(newPref, request.who)
     }
@@ -1352,7 +1375,9 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   SECURITY // don't allow if user listing disabled, & isn't staff [8FKU2A4]
-  def listAllUsers(usernamePrefix: String): Action[Unit] = GetAction { request =>
+  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
+  def listAllUsers(usernamePrefix: St): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     // Authorization check: Is a member? Add MemberGetAction?
     request.theMember
 
@@ -1362,6 +1387,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
 
 
   SECURITY // user listing disabled? [8FKU2A4]
+  CHECK_AUTHN_STRENGTH // maybe sometimes not allowed from embedded comments pages?
   def listMembersPubApi(usernamePrefix: String, usersOnly: Boolean)
         : Action[Unit] = GetAction { request =>
     // Allowed also if not logged in — so can use from a non-Talkyard client,
@@ -1399,7 +1425,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
   /** Listing usernames on a particular page is okay, if one may see the page
     * — however, listing all usernames for the whole site, isn't always okay. [8FKU2A4]
     */
-  def listUsernames(pageId: PageId, prefix: String): Action[Unit] = GetAction { request =>
+  def listUsernames(pageId: PageId, prefix: St): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.dao
 
     val pageMeta = dao.getPageMeta(pageId) getOrElse throwIndistinguishableNotFound("EdE4Z0B8P5")
@@ -1410,6 +1437,8 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     // — someone who don't want strangers and new members to see hens profile —
     // then, would need to exclude those accounts here.
 
+    CHECK_AUTHN_STRENGTH  // disallow if just sid part 1+2 but not embedded page
+
     throwNoUnless(Authz.maySeePage(
       pageMeta, request.user, dao.getGroupIdsOwnFirst(request.user),
       dao.getAnyPrivateGroupTalkMembers(pageMeta), categoriesRootLast,
@@ -1584,7 +1613,7 @@ class UserController @Inject()(cc: ControllerComponents, edContext: EdContext)
     val anonNNN = dao.deleteUser(userId, request.who)
     val response = OkSafeJsValue(JsString(anonNNN.username))
     // Log the user out, if hen deleted hens own account.
-    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookie)
+    if (isOneself) response.discardingCookies(context.security.DiscardingSessionCookies: _*)
     else response
   }
 

programming_language: Scala
filename: ViewPageController.scala
change_type: ModificationType.MODIFY
diff: @@ -29,10 +29,10 @@ import scala.concurrent.Future
 import ed.server.{EdContext, EdController, RenderedPage}
 import javax.inject.Inject
 import ViewPageController._
-import debiki.dao.{UsersOnlineStuff, NoUsersOnlineStuff}
+import debiki.dao.NoUsersOnlineStuff
 import ed.server.auth.MaySeeOrWhyNot
-import ed.server.security.EdSecurity
 import talkyard.server.authn.LoginReason
+import talkyard.server.authn.MinAuthnStrength
 import talkyard.server.JsX.JsObjOrNull
 
 
@@ -53,6 +53,7 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
 
 
 
+  CHECK_AUTHN_STRENGTH
   def loadPost(pageId: PageId, postNr: PostNr): Action[Unit] = GetActionAllowAnyone { request =>
     // Similar to getPageAsJsonImpl and getPageAsHtmlImpl, keep in sync. [7PKW0YZ2]
 
@@ -69,6 +70,7 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
         throwForbidden("EdE4F8WV0", "Account not approved")
     }
 
+    // & sid leveL?
     val (maySeeResult, debugCode) = dao.maySeePostUseCache(pageId, postNr, request.user)
     maySeeResult match {
       case MaySeeOrWhyNot.YesMaySee =>
@@ -103,6 +105,7 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
     * Good for analytics and understanding what the users do at the site?
     * The SPA stuff is just an optimization.
     */
+  CHECK_AUTHN_STRENGTH
   def viewPage(path: String): Action[Unit] = AsyncGetActionAllowAnyone { request =>
     if (request.queryString.get("json").isDefined) {
       getPageAsJson(path, request)
@@ -185,6 +188,7 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
 
     val pageRequest = new PageRequest[Unit](
       request.site,
+      request.anyTySession,
       sid = request.sid,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
@@ -242,8 +246,9 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
   }
 
 
-  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits, maxBytes = 2) {
-        request =>
+  def markPageAsSeen(pageId: PageId): Action[JsValue] = PostJsonAction(NoRateLimits,
+        MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 2) { request =>
+    CHECK_AUTHN_STRENGTH
     val watchbar = request.dao.getOrCreateWatchbar(request.theUserId)
     val newWatchbar = watchbar.markPageAsSeen(pageId)
     request.dao.saveWatchbar(request.theUserId, newWatchbar)
@@ -303,9 +308,15 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
                 die("DwE7KEWK2", "Both not approved and approved")
             }
         }
-        var forbidden = ForbiddenResult(s"TyM0APPR_-$code", message)
-        if (logout) forbidden = forbidden.discardingCookies(security.DiscardingSessionCookie)
-        return Future.successful(forbidden)
+        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)
       }
     }
 
@@ -350,6 +361,7 @@ class ViewPageController @Inject()(cc: ControllerComponents, edContext: EdContex
 
     val pageRequest = new PageRequest[Unit](
       request.site,
+      request.anyTySession,
       sid = request.sid,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,
@@ -544,6 +556,7 @@ object ViewPageController {
 
     new DummyPageRequest(
       request.site,
+      request.anyTySession,
       sid = request.sid,
       xsrfToken = request.xsrfToken,
       browserId = request.browserId,

programming_language: Scala
filename: VoteController.scala
change_type: ModificationType.MODIFY
diff: @@ -29,6 +29,8 @@ import javax.inject.Inject
 import play.api.libs.json._
 import play.api.mvc.{Action, ControllerComponents}
 import talkyard.server.JsX.{JsUser, JsStringOrNull}
+import talkyard.server.authn.MinAuthnStrength
+
 
 
 /** Handles votes, e.g. "I like this comment" or "this comment is faulty" votes.
@@ -46,8 +48,9 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
     *   action: "CreateVote"  # or "DeleteVote"
     *   postIdsRead: [1, 9, 53, 82]
     */
-  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost, maxBytes = 500) {
-        request: JsonPostRequest =>
+  def handleVotes: Action[JsValue] = PostJsonAction(RateLimits.RatePost,
+          MinAuthnStrength.EmbeddingStorageSid12, maxBytes = 500) {
+          request: JsonPostRequest =>
     import request.{body, dao, theRequester => requester}
     val anyPageId = (body \ "pageId").asOpt[PageId]
 
@@ -100,6 +103,8 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
           anyEmbeddingUrl = anyEmbeddingUrl, lazyCreatePageInCatId = lazyCreatePageInCatId,
           request)
 
+    CHECK_AUTHN_STRENGTH
+
     if (delete) {
       dao.deleteVoteIfAuZ(pageId, postNr, voteType, voterId = request.theUser.id)
     }
@@ -124,12 +129,15 @@ class VoteController @Inject()(cc: ControllerComponents, edContext: EdContext)
   }
 
 
-  def loadVoters(postId: PostId, voteType: Int): Action[Unit] = GetAction { request =>
+  def loadVoters(postId: PostId, voteType: Int): Action[U] = GetActionRateLimited(
+          RateLimits.ReadsFromDb, MinAuthnStrength.EmbeddingStorageSid12) { request =>
     import request.{dao, requester}
 
     val pageMeta: PageMeta = dao.getThePageMetaForPostId(postId)
     val categoriesRootLast = dao.getAncestorCategoriesRootLast(pageMeta.categoryId)
 
+    CHECK_AUTHN_STRENGTH
+
     throwNoUnless(Authz.maySeePage(
       pageMeta, requester,
       dao.getGroupIdsOwnFirst(requester),

programming_language: Scala
filename: JsonUtils.scala
change_type: ModificationType.MODIFY
diff: @@ -74,18 +74,47 @@ object JsonUtils {   MOVE // to talkyard.server.parser.JsonParSer
     }
   }
 
+  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}")
+    }
+  }
+
   def asJsObject(json: JsValue, what: St): JsObject =
     json match {
       case o: JsObject => o
-      case _ => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject")
+      case x => throwBadJson("TyE0JSOBJ", s"$what is not a JsObject, it is a: ${classNameOf(x)}")
     }
 
   def asJsArray(json: JsValue, what: St): Seq[JsValue] =
     json match {
       case a: JsArray => a.value
-      case _ => throwBadJson("TyE0JSARR", s"$what is not a JsArray")
+      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 parseJsObject(json: JsValue, fieldName: St): JsObject =
     readJsObject(json, fieldName)
 
@@ -122,9 +151,9 @@ object JsonUtils {   MOVE // to talkyard.server.parser.JsonParSer
     }
   }
 
-  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[JsArray] =
+  def parseOptJsArray(jv: JsValue, fieldName: St): Opt[IndexedSeq[JsValue]] =
     (jv \ fieldName).toOption map {
-      case a: JsArray => a
+      case a: JsArray => a.value
       case JsNull => return None
       case bad =>
         throwBadJson(
@@ -355,19 +384,28 @@ object JsonUtils {   MOVE // to talkyard.server.parser.JsonParSer
 
   def readOptInt(json: JsValue, fieldName: String, altName: String = "",
           min: Opt[i32] = None, max: Opt[i32] = None): Option[Int] = {
-    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
+    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
+  }
 
   def readLong(json: JsValue, fieldName: String): Long =
+    parseInt64(json, fieldName)
+
+  def parseInt64(json: JsValue, fieldName: St): i64 =
     readOptLong(json, fieldName) getOrElse throwMissing("EsE6Y8FW2", fieldName)
 
   def parseOptLong(json: JsValue, fieldName: St): Opt[i64] =

programming_language: Scala
filename: ReactJson.scala
change_type: ModificationType.MODIFY
diff: @@ -24,6 +24,7 @@ import debiki.dao._
 import ed.server.auth.ForumAuthzContext
 import ed.server.auth.Authz
 import ed.server.http._
+import ed.server.security.{SidStatus, SidOk}
 import java.{lang => jl, util => ju}
 import org.jsoup.Jsoup
 import org.jsoup.nodes.{Element => jsoup_Element}
@@ -793,7 +794,7 @@ class JsonMaker(dao: SiteDao) {
     val site = if (requester.isStaffOrCoreMember) dao.getSite else None
 
     dao.readOnlyTransaction { tx =>
-      requestersJsonImpl(requester, pageRequest.pageId, watchbarWithTitles,
+      requestersJsonImpl(pageRequest.sid, requester, pageRequest.pageId, watchbarWithTitles,
             restrTopicsCatsLinks, permissions, permsOnSiteTooMany,
             unapprovedPostAuthorIds, myGroupsEveryoneLast, site, tx)
     }
@@ -816,7 +817,7 @@ class JsonMaker(dao: SiteDao) {
     val site = if (requester.isStaffOrCoreMember) dao.getSite else None
 
     dao.readOnlyTransaction { tx =>
-      requestersJsonImpl(requester, anyPageId = None, watchbarWithTitles,
+      requestersJsonImpl(request.sid, requester, anyPageId = None, watchbarWithTitles,
             RestrTopicsCatsLinks(JsArray(), Nil, Nil, Nil, Set.empty),
             permissions, permsOnSiteTooMany,
             unapprovedPostAuthorIds = Set.empty, myGroupsEveryoneLast, site, tx)
@@ -824,7 +825,8 @@ class JsonMaker(dao: SiteDao) {
   }
 
 
-  private def requestersJsonImpl(requester: Participant, anyPageId: Option[PageId],
+  private def requestersJsonImpl(
+        sid: SidStatus, requester: Participant, anyPageId: Option[PageId],
         watchbar: WatchbarWithTitles, restrTopicsCatsLinks: RestrTopicsCatsLinks,
         permissions: Seq[PermsOnPages], permsOnSiteTooMany: PermsOnSite,
         unapprovedPostAuthorIds: Set[UserId],
@@ -938,9 +940,17 @@ class JsonMaker(dao: SiteDao) {
             "marksByPostId" -> JsObject(Nil)))
     }
 
+    // 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
+    }
+
     // Somewhat dupl code, (2WB4G7) and [B28JG4].
     var json = Json.obj(
       "dbgSrc" -> "4JKW7A0",
+      "mySidPart1" -> sidPart1,
       "id" -> JsNumber(requester.id),
       "userId" -> JsNumber(requester.id), // try to remove, use 'id' instead
       "username" -> JsStringOrNull(requester.anyUsername),

programming_language: Scala
filename: SiteDao.scala
change_type: ModificationType.MODIFY
diff: @@ -126,6 +126,7 @@ class SiteDao(
   with SpecialContentDao
   with ed.server.auth.AuthzSiteDaoMixin
   with talkyard.server.authn.AuthnSiteDaoMixin
+  with talkyard.server.sess.SessionSiteDaoMixin
   with ForumDao
   with CategoriesDao
   with PagesDao
@@ -315,6 +316,9 @@ class SiteDao(
     dbDao2.readOnlySiteTransaction(siteId, mustBeSerializable = true) { fn(_) }
 
   def readOnlyTransactionTryReuse[R](anyTx: Option[SiteTransaction])(fn: SiteTransaction => R): R =
+    readTxTryReuse(anyTx)(fn)
+
+  def readTxTryReuse[R](anyTx: Opt[SiteTx])(fn: SiteTx => R): R =
     anyTx match {
       case Some(tx) => fn(tx)
       case None => readOnlyTransaction(fn)

programming_language: Scala
filename: SystemDao.scala
change_type: ModificationType.MODIFY
diff: @@ -702,6 +702,12 @@ class SystemDao(
 
   // ----- The janitor actor
 
+  def deletePersonalDataFromOldSessions(): U = {
+    writeTxLockAllSites { tx =>
+      tx.deletePersonalDataFromOldSessions()
+    }
+  }
+
   def deletePersonalDataFromOldAuditLogEntries(): U = {
     writeTxLockAllSites { tx =>
       tx.deletePersonalDataFromOldAuditLogEntries()

programming_language: Scala
filename: UserDao.scala
change_type: ModificationType.MODIFY
diff: @@ -364,7 +364,7 @@ trait UserDao {
     val cappedDays = math.min(numDays, 365 * 110)
     val now = globals.now()
 
-    val user = readWriteTransaction { tx =>
+    writeTx { (tx, staleStuff) =>
       var user = tx.loadTheUserInclDetails(userId)
       if (user.isAdmin)
         throwForbidden("DwE4KEF24", "Cannot suspend admins")
@@ -375,11 +375,14 @@ trait UserDao {
         suspendedTill = Some(suspendedTill),
         suspendedById = Some(suspendedById),
         suspendedReason = Some(reason.trim))
+
       tx.updateUserInclDetails(user)
-      user
-    }
+      staleStuff.addPatIds(Set(userId))
 
-    logout(user.noDetails, bumpLastSeen = false)
+      logout(user.noDetails, bumpLastSeen = false, anyTx = Some(tx, staleStuff))
+      terminateSessions(  // [end_sess]
+            forPatId = user.id, all = true, anyTx = Some(tx, staleStuff))
+    }
   }
 
 
@@ -758,12 +761,19 @@ trait UserDao {
   }
 
 
-  def logout(pat: Pat, bumpLastSeen: Bo): U = {
-    readWriteTransaction { tx =>
+  def logout(pat: Pat, bumpLastSeen: Bo, anyTx: Opt[(SiteTx, StaleStuff)] = None): U = {
+    if (bumpLastSeen) writeTxTryReuse(anyTx) { (tx, staleStuff) =>
       addUserStats(UserStats(pat.id, lastSeenAt = tx.now))(tx)
+      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]
     pubSub.unsubscribeUser(siteId, pat)
-    removeUserFromMemCache(pat.id)
   }
 
 
@@ -2190,7 +2200,7 @@ trait UserDao {
     * - EdT5WKBWQ2
     */
   def deleteUser(userId: UserId, byWho: Who): UserInclDetails = {
-    val (user, usersGroupIds) = readWriteTransaction { tx =>
+    val (user, usersGroupIds) = writeTx { (tx, staleStuff) =>
       tx.deferConstraints()
 
       val deleter = tx.loadTheParticipant(byWho.id)
@@ -2284,6 +2294,9 @@ trait UserDao {
       tx.removeDeletedMemberFromAllPages(userId)
       tx.removeDeletedMemberFromAllGroups(userId)
 
+      // logout(..) done below.  [end_sess]
+      terminateSessions(forPatId = userId, all = true, anyTx = Some(tx, staleStuff))
+
       // Clear the page cache, by clearing all caches.
       emptyCacheImpl(tx)  ; SHOULD_OPTIMIZE // use staleStuff.addPagesWithVisiblePostsBy() instead
 
@@ -2296,7 +2309,11 @@ trait UserDao {
     // (this not needed, since cleared the site cache just above. Do anyway.)
     uncacheOnesGroupIds(Seq(userId))
     uncacheGroupsMemberLists(usersGroupIds)
+
+    // Or just call:  logout(user, bumpLastSeen = false)
+    pubSub.unsubscribeUser(siteId, user.briefUser)
     removeUserFromMemCache(userId)
+    // terminateSessions() done above.
 
     user
   }

programming_language: Scala
filename: EdAppLoader.scala
change_type: ModificationType.MODIFY
diff: @@ -106,6 +106,7 @@ class EdAppComponents(appLoaderContext: ApplicationLoader.Context)
     new _root_.controllers.SpecialContentController(cc, context),
     new _root_.controllers.ModerationController(cc, context),
     new _root_.controllers.UserController(cc, context),
+    new talkyard.server.sess.SessionController(cc, context),
     new _root_.controllers.UnsubscriptionController(cc, context),
     new ed.server.summaryemails.UnsubFromSummariesController(cc, context),
     new _root_.controllers.InviteController(cc, context),

programming_language: Scala
filename: EdController.scala
change_type: ModificationType.MODIFY
diff: @@ -11,6 +11,7 @@ import play.api.libs.json.JsValue
 import play.api.mvc._
 import play.{api => p}
 import scala.concurrent.{ExecutionContext, Future}
+import talkyard.server.authn.MinAuthnStrength
 
 
 
@@ -30,7 +31,8 @@ class EdController(cc: ControllerComponents, val context: EdContext)
     PlainApiAction(cc.parsers.empty, NoRateLimits, avoidCookies = avoidCookies).async(f)
 
   def AsyncGetActionAllowAnyone(f: GetRequest => Future[Result]): mvc.Action[Unit] =
-    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true).async(f)
+    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
+          allowAnyone = true).async(f)
 
   def AsyncGetActionIsLogin(f: GetRequest => Future[Result]): mvc.Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits, isLogin = true).async(f)
@@ -38,26 +40,42 @@ class EdController(cc: ControllerComponents, val context: EdContext)
   def AsyncGetActionIsLoginRateLimited(f: GetRequest => Future[Result]): mvc.Action[Unit] =
     PlainApiAction(cc.parsers.empty, RateLimits.Login, isLogin = true).async(f)
 
-  def AsyncGetActionRateLimited(rateLimits: RateLimits)(f: GetRequest => Future[Result])
+  def AsyncGetActionRateLimited(rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        )(f: GetRequest => Future[Result])
         : mvc.Action[Unit] =
-    PlainApiAction(cc.parsers.empty, rateLimits).async(f)
+    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength).async(f)
 
+  @deprecated("Use GetActionRateLimited instead, remove this, and rename it to this", "")
   def GetAction(f: GetRequest => Result): Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits)(f)
 
+  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?
   def GetActionAllowAnyone(f: GetRequest => Result): Action[Unit] =
-    PlainApiAction(cc.parsers.empty, NoRateLimits, allowAnyone = true)(f)
+    PlainApiAction(cc.parsers.empty, NoRateLimits, MinAuthnStrength.EmbeddingStorageSid12,
+          allowAnyone = true)(f)
 
   def GetActionAllowAnyoneRateLimited(rateLimits: RateLimits, avoidCookies: Boolean = false)
         (f: GetRequest => Result): Action[Unit] =
-    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = true, avoidCookies = avoidCookies)(f)
+    PlainApiAction(cc.parsers.empty, rateLimits, MinAuthnStrength.EmbeddingStorageSid12,
+          allowAnyone = true, avoidCookies = avoidCookies)(f)
 
   def GetActionIsLogin(f: GetRequest => Result): Action[Unit] =
     PlainApiAction(cc.parsers.empty, NoRateLimits, isLogin = true)(f)
 
-  def GetActionRateLimited(rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
+  def GetActionRateLimited(
+        rateLimits: RateLimits = RateLimits.ExpensiveGetRequest,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
         allowAnyone: Boolean = false)(f: GetRequest => Result): Action[Unit] =
-    PlainApiAction(cc.parsers.empty, rateLimits, allowAnyone = allowAnyone)(f)
+    PlainApiAction(cc.parsers.empty, rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
 
   def StaffGetAction(f: GetRequest => Result): Action[Unit] =
     PlainApiActionStaffOnly(cc.parsers.empty)(f)
@@ -91,11 +109,13 @@ class EdController(cc: ControllerComponents, val context: EdContext)
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
       rateLimits, allowAnyone = allowAnyone, avoidCookies = avoidCookies).async(f)
 
-  def PostJsonAction(rateLimits: RateLimits, maxBytes: Int,
+  def PostJsonAction(rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        maxBytes: Int,
         allowAnyone: Boolean = false, isLogin: Boolean = false)(
         f: JsonPostRequest => Result): Action[JsValue] =
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
-      rateLimits, allowAnyone = allowAnyone, isLogin = isLogin)(f)
+        rateLimits, minAuthnStrength, allowAnyone = allowAnyone, isLogin = isLogin)(f)
 
   def AsyncUserPostJsonAction(rateLimits: RateLimits, maxBytes: i32,
         avoidCookies: Bo = false)(
@@ -108,15 +128,19 @@ class EdController(cc: ControllerComponents, val context: EdContext)
     PlainApiAction(cc.parsers.json(maxLength = maxBytes),
       rateLimits, authnUsersOnly = true)(f)
 
-  def PostTextAction(rateLimits: RateLimits, maxBytes: Int, allowAnyone: Boolean = false)(
+  def PostTextAction(rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        maxBytes: Int, allowAnyone: Boolean = false)(
         f: ApiRequest[String] => Result): Action[String] =
     PlainApiAction(cc.parsers.text(maxLength = maxBytes),
-      rateLimits, allowAnyone = allowAnyone)(f)
+      rateLimits, minAuthnStrength, allowAnyone = allowAnyone)(f)
 
   SECURITY // add rate limits for staff too
-  def StaffPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
+  def StaffPostJsonAction(
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
     PlainApiActionStaffOnly(
-      cc.parsers.json(maxLength = maxBytes))(f)
+      cc.parsers.json(maxLength = maxBytes), minAuthnStrength)(f)
 
   SECURITY // add rate limits for admins — use AdminPostJsonAction2, then remove this & rm '2' from name.
   def AdminPostJsonAction(maxBytes: Int)(f: JsonPostRequest => Result): Action[JsValue] =
@@ -156,17 +180,11 @@ class EdController(cc: ControllerComponents, val context: EdContext)
   // ----- Site id lookup
 
 
-  def originOf(request: DebikiRequest[_]) =
+  def originOf(request: DebikiRequest[_]): St =
     globals.originOf(request.underlying)
 
-  def originOf(request: RequestHeader) =
+  def originOf(request: RequestHeader): St =
     globals.originOf(request)
 
 
-  def daoFor(request: Request[_]) = {
-    val site = globals.lookupSiteOrThrow(originOf(request))
-    globals.siteDao(site.id)
-  }
-
-
 }

programming_language: Scala
filename: AuthzSiteDaoMixin.scala
change_type: ModificationType.MODIFY
diff: @@ -215,9 +215,12 @@ trait AuthzSiteDaoMixin {
 
 
   /** Returns true/false, + iff false, a why-forbidden debug reason code.
+    *
+    * 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: Option[Participant])
-        : (MaySeeOrWhyNot, String) = {
+  def maySeePostUseCache(pageId: PageId, postNr: PostNr, user: Opt[Pat])
+        : (MaySeeOrWhyNot, St) = {
     maySeePostImpl(pageId, postNr, user, anyPost = None, anyTx = None)
   }
 

programming_language: Scala
filename: DebikiRequest.scala
change_type: ModificationType.MODIFY
diff: @@ -51,6 +51,10 @@ abstract class AuthnReqHeader extends SomethingToRateLimit {
   private def globals = dao.context.globals
 
   def site: SiteBrief
+
+  def anyTySession: Opt[TySession]
+  def tySession: TySession = anyTySession getOrElse throwForbidden("TyE0SESS", "Not logged in")
+
   def sid: SidStatus
   def xsrfToken: XsrfOk
   def browserId: Option[BrowserId]
@@ -79,7 +83,7 @@ abstract class AuthnReqHeader extends SomethingToRateLimit {
   }
 
   def isViaApiSecret: Boolean = sid match { // should be case obj AuthnMethod.ApiSecret instead? [5BKRH02]
-    case SidOk("_api_secret_", 0, _) => true
+    case SidOk(TySession.ApiSecretPart12, 0, _) => true
     case _ => false
   }
 
@@ -165,7 +169,6 @@ abstract class AuthnReqHeader extends SomethingToRateLimit {
   def isGuest: Boolean = user.exists(_.isGuest)
   def isStaff: Boolean = user.exists(_.isStaff)
 
-  def session: mvc.Session = request.session
 
   def ip: IpAddress = security.realOrFakeIpOf(request)
 

programming_language: Scala
filename: PageRequest.scala
change_type: ModificationType.MODIFY
diff: @@ -37,6 +37,7 @@ import play.api.mvc.Request
   */
 class PageRequest[A](
   val site: SiteBrief,
+  val anyTySession: Opt[TySession],
   val sid: SidStatus,
   val xsrfToken: XsrfOk,
   val browserId: Option[BrowserId],
@@ -140,6 +141,7 @@ class PageRequest[A](
   */
 class DummyPageRequest[A](
   siteIdAndCanonicalHostname: SiteBrief,
+  anyTySession: Opt[TySession],
   sid: SidStatus,
   xsrfToken: XsrfOk,
   browserId: Option[BrowserId],
@@ -149,7 +151,7 @@ class DummyPageRequest[A](
   pageMeta: PageMeta,
   dao: SiteDao,
   request: Request[A]) extends PageRequest[A](
-    siteIdAndCanonicalHostname, sid, xsrfToken, browserId, user, pageExists,
+    siteIdAndCanonicalHostname, anyTySession, sid, xsrfToken, browserId, user, pageExists,
     pagePath, Some(pageMeta), altPageId = None, embeddingUrl = None,
     dao = dao, request = request) {
 

programming_language: Scala
filename: PlainApiActions.scala
change_type: ModificationType.MODIFY
diff: @@ -34,9 +34,10 @@ import scala.collection.mutable
 import play.api.http.{HeaderNames => p_HNs}
 import play.api.mvc.{Results => p_Results}
 import talkyard.server.TyLogging
+import talkyard.server.authn.MinAuthnStrength
 
 
-/** Play Framework Actions for requests to Debiki's HTTP API.
+/** Play Framework Actions for requests to Talkyard's HTTP API.
   */
 class PlainApiActions(
   val safeActions: SafeActions,
@@ -46,21 +47,29 @@ class PlainApiActions(
 
   import EdHttp._
   import security.DiscardingSecureCookie
-  import security.DiscardingSessionCookie
+  import security.DiscardingSessionCookies
   import safeActions.ExceptionAction
 
-  def PlainApiAction[B](parser: BodyParser[B],
-        rateLimits: RateLimits, allowAnyone: Bo = false, isLogin: Bo = false,
+  def PlainApiAction[B](
+        parser: BodyParser[B],
+        rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+        allowAnyone: Bo = false,
+        isLogin: Bo = false,
         authnUsersOnly: Bo = false,
-        avoidCookies: Bo = false, skipXsrfCheck: Bo = false)
-        : ActionBuilder[ApiRequest, B] =
-    PlainApiActionImpl(parser, rateLimits,
+        avoidCookies: Bo = false,
+        skipXsrfCheck: Bo = false,
+        ): ActionBuilder[ApiRequest, B] =
+    PlainApiActionImpl(parser, rateLimits, minAuthnStrength = minAuthnStrength,
         authnUsersOnly = authnUsersOnly,
         allowAnyone = allowAnyone, isLogin = isLogin, avoidCookies = avoidCookies,
         skipXsrfCheck = skipXsrfCheck)
 
-  def PlainApiActionStaffOnly[B](parser: BodyParser[B]): ActionBuilder[ApiRequest, B] =
-    PlainApiActionImpl(parser, NoRateLimits, staffOnly = true)
+  def PlainApiActionStaffOnly[B](
+          parser: BodyParser[B],
+          minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
+          ): ActionBuilder[ApiRequest, B] =
+    PlainApiActionImpl(parser, NoRateLimits, minAuthnStrength, staffOnly = true)
 
   def PlainApiActionAdminOnly[B](rateLimits: RateLimits, parser: BodyParser[B])
         : ActionBuilder[ApiRequest, B] =
@@ -68,7 +77,7 @@ class PlainApiActions(
 
   def PlainApiActionApiSecretOnly[B](rateLimits: RateLimits, parser: BodyParser[B])
         : ActionBuilder[ApiRequest, B] =
-    PlainApiActionImpl(parser, rateLimits, viaApiSecretOnly = true)
+    PlainApiActionImpl(parser, rateLimits, MinAuthnStrength.ApiSecret, viaApiSecretOnly = true)
 
   def PlainApiActionSuperAdminOnly[B](parser: BodyParser[B])
         : ActionBuilder[ApiRequest, B] =
@@ -87,6 +96,7 @@ class PlainApiActions(
     */
   def PlainApiActionImpl[B](aParser: BodyParser[B],
         rateLimits: RateLimits,
+        minAuthnStrength: MinAuthnStrength = MinAuthnStrength.Normal,
         adminOnly: Boolean = false,
         staffOnly: Boolean = false,
         authnUsersOnly: Bo = false,
@@ -277,7 +287,8 @@ class PlainApiActions(
 
         val sysbot = dao.getTheUser(SysbotUserId)
         return runBlockIfAuthOk(request, site, dao, Some(sysbot),
-              SidOk("_api_secret_", 0, Some(SysbotUserId)),
+              Some(TySession.singleApiCallSession(asPatId = SysbotUserId)),
+              SidOk(TySession.ApiSecretPart12, 0, Some(SysbotUserId)),
               XsrfOk("_email_webhook_"), None, block)
       }
 
@@ -327,8 +338,11 @@ class PlainApiActions(
       // if isn't sysbot.
 
       runBlockIfAuthOk(request, site, dao, Some(user),
+          Some(TySession.singleApiCallSession(asPatId = user.id)),
           // SECURITY minor: Less error prone with a Bool field instead of this magic string.
-          SidOk("_api_secret_", 0, Some(user.id)), XsrfOk("_api_secret_"), None, block)
+          // ... getting fixed, see TySession just above, [btr_sid].
+          SidOk(TySession.ApiSecretPart12, 0, Some(user.id)),
+          XsrfOk(TySession.ApiSecretPart12), None, block)
     }
 
 
@@ -338,11 +352,11 @@ class PlainApiActions(
           block: ApiRequest[A] => Future[Result]): Future[Result] = {
 
       // Why avoid cookies? In an embedded comments iframe, cookies frequently get blocked
-      // by Privacy Badger or iOS or browser settings for 3rd-party-cookies.
+      // by iOS ITP or FF ETP or browser settings for 3rd-party-cookies.
       // The embedded comments show-page etc endpoints sets avoidCookies,
       // so we know, here, that we should avoid setting any cookies.  [NOCOOKIES]
       // And, for subsequent requests — to *other* endpoints — the browser Javascript code sets
-      // the AvoidCookiesHeaderName header, so we won't froget that we should avoid cookies here.
+      // the AvoidCookiesHeaderName header, so we won't forget that we should avoid cookies here.
       val hasCookiesAlready = request.cookies.exists(_.name != "esCoE2eTestPassword")
       val maySetCookies = hasCookiesAlready || {   // TODO
         val shallAvoid = avoidCookies || {
@@ -358,7 +372,9 @@ class PlainApiActions(
 
       val expireIdleAfterMins = siteSettings.expireIdleAfterMins
 
-      val (actualSidStatus, xsrfOk, newCookies) = corsInfo match {
+      val CheckSidAndXsrfResult(
+              anyTySession, actualSidStatus, xsrfOk, newCookies, delFancySidCookies) =
+            corsInfo match {
         case ci: CorsInfo.OkayCrossOrigin =>
           // Cross-origin requests with credentials (i.e. session id cookie)
           // not yet tested and thought thrown.
@@ -370,17 +386,19 @@ class PlainApiActions(
           // cross-origin requests.
           // Currently only publicly accessible data can be seen, since proceeding
           // with no session id.
-          (SidAbsent, XsrfOk("cors_no_xsrf"), Nil)
+          CheckSidAndXsrfResult(
+                None, SidAbsent, XsrfOk("cors_no_xsrf"), Nil, Nil)
 
-        case _: CorsInfo.SameOrigins | CorsInfo.NotCorsNoOriginHeader =>
+        case ci =>
+          dieIf(ci.isCrossOrigin, "TyEJ2503TKHJ")
           security.checkSidAndXsrfToken(
-                request, anyRequestBody = Some(request.body), siteId = site.id,
+                request, anyRequestBody = Some(request.body), site, dao,
                 expireIdleAfterMins, maySetCookies = maySetCookies,
                 skipXsrfCheck = skipXsrfCheck)
       }
 
       // Ignore and delete any broken or expired session id cookie.
-      val (mendedSidStatus, deleteSidCookie) =
+      val (mendedSidStatus, deleteAllSidCookies) =
         if (actualSidStatus.canUse) (actualSidStatus, false)
         else (SidAbsent, true)
 
@@ -411,33 +429,40 @@ class PlainApiActions(
                 }
 
           runBlockIfAuthOk(request, site, dao, anyUserMaybeSuspended,
-                mendedSidStatus, xsrfOk, anyBrowserId, block)
+                anyTySession, mendedSidStatus, xsrfOk, anyBrowserId, block)
         }
         catch {
+          // CLEAN_UP have getUserBySessionId() return an error instead of throwing
           case _: LoginNotFoundException =>
             // This might happen if I manually deleted stuff from the
             // database during development, or if the server has fallbacked
             // to a standby database.
+            // (Does it make sense to delete any related session from sessions_t?)
             throw ResultException(InternalErrorResult2(i"""
               |Internal error, please try again. For example, reload the page. [DwE034ZQ3]
               |
               |Details: A certain login id has become invalid. I just gave you a new id,
               |but you will probably need to login again.""")
-              .discardingCookies(DiscardingSessionCookie))
+              .discardingCookies(DiscardingSessionCookies: _*))
         }
 
       val resultOkSid =
-        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteSidCookie) {
+        if (newCookies.isEmpty && newBrowserIdCookie.isEmpty && !deleteAllSidCookies
+              && delFancySidCookies.isEmpty) {
           resultOldCookies
         }
         else {
           resultOldCookies.map({ result =>
             var resultWithCookies = result
-              .withCookies(newCookies ::: newBrowserIdCookie: _*)
-              .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
-            if (deleteSidCookie) {
+                  .withCookies(newCookies ::: newBrowserIdCookie: _*)
+                  .withHeaders(safeActions.MakeInternetExplorerSaveIframeCookiesHeader)
+            if (deleteAllSidCookies) {
+              resultWithCookies =
+                    resultWithCookies.discardingCookies(DiscardingSessionCookies: _*)
+            }
+            else if (delFancySidCookies.nonEmpty) {
               resultWithCookies =
-                resultWithCookies.discardingCookies(DiscardingSessionCookie)
+                    resultWithCookies.discardingCookies(delFancySidCookies: _*)
             }
             resultWithCookies
           })(executionContext)
@@ -448,27 +473,37 @@ class PlainApiActions(
 
 
     private def runBlockIfAuthOk[A](request: Request[A], site: SiteBrief, dao: SiteDao,
-          anyUserMaybeSuspended: Option[Participant], sidStatus: SidStatus,
+          anyUserMaybeSuspended: Option[Participant],
+          anyTySession: Opt[TySession], sidStatus: SidStatus,
           xsrfOk: XsrfOk, browserId: Option[BrowserId], block: ApiRequest[A] => Future[Result])
           : Future[Result] = {
 
       // Maybe the user was logged in in two different browsers, and deleted hens account
-      // in one browser and got logged out there, only.
-      if (anyUserMaybeSuspended.exists(_.isDeleted))
+      // 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")
         return Future.successful(
-          ForbiddenResult("TyEUSRDLD", "That account has been deleted")
-            .discardingCookies(DiscardingSessionCookie))
+              ForbiddenResult("TyEUSRDLD", "That account has been deleted")
+                  .discardingCookies(DiscardingSessionCookies: _*))
+      }
 
       val isSuspended = anyUserMaybeSuspended.exists(_.isSuspendedAt(new ju.Date))
 
-      if (isSuspended && request.method != "GET")
+      if (isSuspended && request.method != "GET") {
+        // A race. Any session already deleted by UserDao [end_sess],
+        dieIf(dao.listPatsSessions(anyUserMaybeSuspended.get.id).nonEmpty, "TyESTILLSID02")
         return Future.successful(
-            ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
-              .discardingCookies(DiscardingSessionCookie))
+              ForbiddenResult("TyESUSPENDED_", "Your account has been suspended")
+                  .discardingCookies(DiscardingSessionCookies: _*))
+      }
 
       val anyUser =
-        if (isSuspended) None
-        else anyUserMaybeSuspended
+            if (!isSuspended) anyUserMaybeSuspended
+            else {
+              // If suspended, one can still see publicly visible things.
+              None
+            }
 
       // Re the !superAdminOnly test: Do allow access for superadmin endpoints,
       // so they can reactivate this site, in case this site is the superadmin site itself.
@@ -494,6 +529,43 @@ class PlainApiActions(
       if (adminOnly && !anyUser.exists(_.isAdmin) && !isLogin)
         throwLoginAsAdmin(request)
 
+      // 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")
+      }
+
       if (superAdminOnly) {
         globals.config.superAdmin.siteIdString match {
           case Some(siteId) if site.id.toString == siteId =>
@@ -590,7 +662,7 @@ class PlainApiActions(
       }
 
       val apiRequest = ApiRequest[A](
-        site, sidStatus, xsrfOk, browserId, anyUser, dao, request)
+        site, anyTySession, sidStatus, xsrfOk, browserId, anyUser, dao, request)
 
       rateLimiter.rateLimit(rateLimits, apiRequest)
 
@@ -646,9 +718,11 @@ class PlainApiActions(
       })(executionContext)
 
       if (isSuspended) {
-        // 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)
+        // 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)
       }
       result
     }

programming_language: Scala
filename: package.scala
change_type: ModificationType.MODIFY
diff: @@ -32,10 +32,11 @@ package object http {
 
   case class AuthnReqHeaderImpl(
     site: SiteBrief,
+    anyTySession: Opt[TySession],
     sid: SidStatus,
     xsrfToken: XsrfOk,
-    browserId: Option[BrowserId],
-    user: Option[Participant],
+    browserId: Opt[BrowserId],
+    user: Opt[Pat],
     dao: SiteDao,
     request: RequestHeader) extends AuthnReqHeader {
   }
@@ -43,10 +44,11 @@ package object http {
 
   case class ApiRequest[A](   // RENAME to AuthnReqImpl
     site: SiteBrief,
+    anyTySession: Opt[TySession],
     sid: SidStatus,
     xsrfToken: XsrfOk,
-    browserId: Option[BrowserId],
-    user: Option[Participant],
+    browserId: Opt[BrowserId],
+    user: Opt[Pat],
     dao: SiteDao,
     request: Request[A]) extends DebikiRequest[A] {
   }

programming_language: Scala
filename: Janitor.scala
change_type: ModificationType.MODIFY
diff: @@ -112,6 +112,7 @@ class JanitorActor(val globals: Globals) extends Actor {
 
   private def findAndDeleteOldStuff(): Unit = {
     val dao = globals.systemDao
+    dao.deletePersonalDataFromOldSessions()
     dao.deletePersonalDataFromOldAuditLogEntries()
     dao.deletePersonalDataFromOldSpamCheckTasks()
     dao.deleteOldUnusedUploads()

programming_language: Scala
filename: SubscriberController.scala
change_type: ModificationType.MODIFY
diff: @@ -25,11 +25,12 @@ import debiki._
 import talkyard.server.JsX
 import ed.server.{EdContext, EdController}
 import ed.server.http._
+import ed.server.security.CheckSidAndXsrfResult
 import javax.inject.Inject
 import org.scalactic.{Bad, Good, Or}
 import play.{api => p}
 import p.libs.json.{JsValue, Json}
-import p.mvc.{Action, ControllerComponents, RequestHeader, Result}
+import p.mvc.{Action, ControllerComponents, RequestHeader => play_RequestHeader, Result}
 import scala.concurrent.Future
 import talkyard.server.TyLogging
 import talkyard.server.RichResult
@@ -46,14 +47,14 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
 
 
   def webSocket: p.mvc.WebSocket = p.mvc.WebSocket.acceptOrResult[JsValue, JsValue] {
-        request: RequestHeader =>
+        request: play_RequestHeader =>
     webSocketImpl(request)
       // map { if  Left[Result = Problem ... log to admin problem log ?  }
       // [ADMERRLOG]
   }
 
 
-  private def webSocketImpl(request: RequestHeader): Future[Either[
+  private def webSocketImpl(request: play_RequestHeader): Future[Either[
         // Either an error response, if we reject the connection.
         Result,
         // Or an In and Out stream, for talking with the client.
@@ -111,7 +112,7 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
 
 
 
-  private def authenticateWebSocket(site: SiteBrief, request: RequestHeader)
+  private def authenticateWebSocket(site: SiteBrief, request: play_RequestHeader)
         : AuthnReqHeaderImpl Or Result = {
     import tyCtx.security
 
@@ -138,12 +139,14 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
     // We check the xsrf token later — since a WebSocket upgrade request
     // cannot have a request body or custom header with xsrf token.
     // (checkSidAndXsrfToken() won't throw for GET requests. [GETNOTHROW])
-    val (sessionId, xsrfOk, newCookies) =
+    val CheckSidAndXsrfResult(anyTySession, sessionId, xsrfOk, newCookies, delFancySidCookies) =
           security.checkSidAndXsrfToken(
-                request, anyRequestBody = None, siteId = site.id,
+                request, anyRequestBody = None, site, dao,
                 expireIdleAfterMins = expireIdleAfterMins, maySetCookies = false,
                 skipXsrfCheck = false)
 
+    COULD // delete any delFancySidCookies.
+
     // Needs to have a cookie already. [WSXSRF]
     dieIf(newCookies.nonEmpty, "TyE503RKDJL2")
     throwForbiddenIf(xsrfOk.value.isEmpty, "TyEWS0XSRFCO", "No xsrf cookie")
@@ -155,7 +158,22 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
     if (sessionId.userId.isEmpty)
       return Bad(ForbiddenResult("TyEWS0SID", "No session id"))
 
-    // For now, let's require a browser id cookie — then, *in some cases* simpler
+    // 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
     // to detect WebSocket abuse or attacks? (Also see: [GETLOGIN] — an id cookie
     // needs to be set also via GET requests, if they're for logging in.)
     val anyBrowserId = security.getAnyBrowserId(request)
@@ -180,17 +198,16 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
 
     if (requesterMaybeSuspended.isDeleted)
       return Bad(ForbiddenResult("TyEWSUSRDLD", "User account deleted")
-          .discardingCookies(security.DiscardingSessionCookie))
-          // + discard browser id co too   *edit:* Why?
+          .discardingCookies(security.DiscardingSessionCookies: _*))
 
     val isSuspended = requesterMaybeSuspended.isSuspendedAt(new java.util.Date)
     if (isSuspended)
       return Bad(ForbiddenResult("TyEWSSUSPENDED", "Your account has been suspended")
-          .discardingCookies(security.DiscardingSessionCookie))
+          .discardingCookies(security.DiscardingSessionCookies: _*))
 
     val requester = requesterMaybeSuspended
 
-    val authnReq = AuthnReqHeaderImpl(site, sessionId, xsrfOk,
+    val authnReq = AuthnReqHeaderImpl(site, anyTySession, sessionId, xsrfOk,
           anyBrowserId, Some(requester), dao, request)
 
     Good(authnReq)

programming_language: Scala
filename: package.scala
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,5 @@
 /**
- * Copyright (C) 2011-2017 Kaj Magnus Lindberg
+ * Copyright (c) 2011-2017, 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
@@ -18,7 +18,6 @@
 package ed.server.security
 
 import com.debiki.core._
-import com.debiki.core.isDevOrTest
 import com.debiki.core.Prelude._
 import debiki.{EdHttp, EffectiveSettings, Globals}
 import ed.server.http.{DebikiRequest, JsonOrFormDataBody}
@@ -27,8 +26,11 @@ import scala.util.Try
 import EdSecurity._
 import ed.server.auth.MayMaybe
 import play.api.http.{HeaderNames => p_HNs}
+import play.api.libs.json.{JsObject, JsString, JsValue}
 import talkyard.server.TyLogger
-
+import talkyard.server.sess.SessionSiteDaoMixin
+import talkyard.server.http
+import ed.server.http.AuthnReqHeader
 
 
 sealed abstract class XsrfStatus { def isOk = false }
@@ -44,6 +46,28 @@ case class XsrfOk(value: String) extends XsrfStatus {
 }
 
 
+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) */
+  }
+}
+
 
 // RENAME to AuthnMethod,
 // and AuthnMethod.ApiSecret [5BKRH02], SidOk –> SessionId, None, BadSessionIdFormat, BadSessionIdHash?
@@ -59,15 +83,32 @@ case object SidAbsent extends SidStatus {
 
 case object SidBadFormat extends SidStatus
 case object SidBadHash extends SidStatus
-case class SidExpired(minutesOld: Long, maxAgeMins: Long) extends SidStatus
+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.
 case class SidOk(
+  // If fancy sid, is part 1 + 2 = 16 + 24 = 40 chars.
   value: String,
   ageInMillis: Long,
   override val userId: Option[UserId]) extends SidStatus {
 
+  // 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
+
   override def canUse = true
 }
 
@@ -81,6 +122,13 @@ case class SidOk(
 case class BrowserId(cookieValue: String, isNew: Boolean)
 
 
+case class CheckSidAndXsrfResult(
+  anyTySession: Opt[TySession],
+  sidStatus: SidStatus,
+  xsrfStatus: XsrfOk,
+  cookiesToAdd: List[Cookie],
+  cookiesToDelete: List[DiscardingCookie])
+
 
 object EdSecurity {
 
@@ -114,6 +162,11 @@ object EdSecurity {
     */
   val SessionIdCookieName = "dwCoSid"
 
+  // See TySession [cookie_theory].
+  val SessionIdPart123CookieName = "TyCoSid123"
+  val SessionIdPart4HttpOnlyCookieName = "TyCoSid4"
+  val SessionIdPart5StrictCookieName = "TyCoSid5"
+
   /** Don't rename. Is used by AngularJS: AngularJS copies the value of
     * this cookie to the HTTP header just above.
     * See: http://docs.angularjs.org/api/ng.$http, search for "XSRF-TOKEN".
@@ -341,22 +394,59 @@ class EdSecurity(globals: Globals) {
    * but not here (the WebSocket upgrade request has no body, no custom headers).
    */
   def checkSidAndXsrfToken[A](request: RequestHeader, anyRequestBody: Option[A],
-        siteId: SiteId, expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
-        : (SidStatus, XsrfOk, List[Cookie]) = {
+        site: SiteBrief, dao: SessionSiteDaoMixin,
+        expireIdleAfterMins: i64, maySetCookies: Bo, skipXsrfCheck: Bo)
+        : CheckSidAndXsrfResult = {
+
+
+    // ----- Check session id
 
     val expireIdleAfterMillis: Long = expireIdleAfterMins * MillisPerMinute
 
     // If we cannot use cookies, then the sid is sent in a header. [NOCOOKIES]
-    val anySessionIdCookieValue: Opt[St] = urlDecodeCookie(SessionIdCookieName, request)
-    val anySessionId: Opt[St] =
-          anySessionIdCookieValue orElse request.headers.get(SessionIdHeaderName)
+    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 now = globals.now()
 
-    val sessionIdStatus: SidStatus =
-          anySessionId.map(
-            checkSessionId(siteId, _, now, expireIdleAfterMillis = expireIdleAfterMillis)
-            ) getOrElse SidAbsent
+    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
 
     // On GET requests, simply accept the value of the xsrf cookie.
     // (On POST requests, however, we check the xsrf form input value)
@@ -365,12 +455,12 @@ class EdSecurity(globals: Globals) {
     val isGet = request.method == "GET"
     val isPost = request.method == "POST"
     val cookies = request.cookies // nice to see in debugger
-    val maybeCredentials = cookies.nonEmpty ||
-          // There's also the session id header, SessionIdHeaderName
-          // (for embedded discussions, cookies then usually won't work).
-          sessionIdStatus != SidAbsent
+    val maybeCredentials = cookies.nonEmpty || anySidHeaderValue.isDefined
+    val definitelyNoCreds = !maybeCredentials
+    dieIf(definitelyNoCreds && sessionIdStatus != SidAbsent,
+          "TyE50RMEG24", s"No creds, still, session id != SidAbsent: $sessionIdStatus")
 
-    val sidXsrfNewCookies: (SidStatus, XsrfOk, List[Cookie]) =
+    val (xsrf, newCookies): (XsrfOk, List[Cookie]) = {
       if (isGet || skipXsrfCheck) {
         // Accept this request, and create new XSRF token if needed.
         // Don't throw anything (for now at least). [GETNOTHROW]
@@ -396,8 +486,7 @@ class EdSecurity(globals: Globals) {
             val cookie = urlEncodeCookie(XsrfCookieName, newXsrfOk.value)
             (newXsrfOk, List(cookie))
           }
-
-        (sessionIdStatus, xsrfOk, anyNewXsrfCookie)
+        (xsrfOk, anyNewXsrfCookie)
       }
       else if (!isPost) {
         // Sometimes people do `curl -I http://...` which sends a HEAD
@@ -411,12 +500,13 @@ class EdSecurity(globals: Globals) {
               "TyE_REQUEST_METHOD__HEAD__NOT_ALLOWED", details)
         throwForbidden( "TyE_REQUEST_METHOD_NOT_ALLOWED", details)
       }
-      else if (isPost && !maybeCredentials) {
+      else if (isPost && definitelyNoCreds) {
         // This might be from a backend server, fetching publicly available data.
         // Example: An Electron or iOS app, calling /-/v0/search, to show
         // in-app help.
         // No credentials are included in the request, so there's no xsrf risk.
-        (SidAbsent, XsrfOk("_no_creds_"), Nil)
+        dieIf(sessionIdStatus != SidAbsent, "TyE502MWEG")
+        (XsrfOk(""), Nil)
       }
       else {
         // Reject this request if the XSRF token is invalid,
@@ -441,12 +531,18 @@ class EdSecurity(globals: Globals) {
         } getOrElse
             throwForbidden("TyE0XSRFTKN_", "No xsrf token")
 
+        val gotAnyCookie =
+              anySillySidInCookie.isDefined ||
+              anyFancySidPart123CookieVal.isDefined ||
+              anyFancySidPart4.isDefined ||
+              anyFancySidPart5.isDefined
+
         val xsrfOk = {
           val xsrfStatus =
-            checkXsrfToken(
-              xsrfToken, anyXsrfCookieValue,
-              thereIsASidCookie = anySessionIdCookieValue.isDefined,
-              now, expireIdleAfterMillis = expireIdleAfterMillis)
+                checkXsrfToken(
+                    xsrfToken, anyXsrfCookieValue,
+                    thereIsASidCookie = gotAnyCookie,
+                    now, expireIdleAfterMillis = expireIdleAfterMillis)
 
           def helpText(theProblem: String, nowHaveOrWillGet: String): String = i"""
             |Security issue: $theProblem. Please try again:
@@ -511,24 +607,32 @@ class EdSecurity(globals: Globals) {
           xsrfStatus.asInstanceOf[XsrfOk]
         }
 
-        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 _ =>
+        sessionIdStatus match {
+          case SidBadFormat | SidBadHash =>
             throw ResultException(
               ForbiddenResult("TyEBADSID", "Bad session ID",
                   "You can try again — I just deleted the bad session ID.")
                 .discardingCookies(
-                  DiscardingSessionCookie))
+                      DiscardingSessionCookies: _*))
+          case _ => // fine
         }
-        dieIf(isDevOrTest && r != (sessionIdStatus, xsrfOk, Nil), "TyE205RKPG36")
-        r
+
+        (xsrfOk, Nil)
       }
+    }
+
+    dieIf(upgrToFancySidCookies.nonEmpty && deleteFancySidCookies.nonEmpty, "TyE5A6MRE25")
+
+    val allCookiesToAdd =
+          if (!maySetCookies) Nil
+          else upgrToFancySidCookies:::newCookies
 
-    sidXsrfNewCookies
+    CheckSidAndXsrfResult(
+          checkSidResult.anyTySession,  // [btr_sid]
+          sessionIdStatus,  // old, will remove later
+          xsrf,
+          cookiesToAdd = allCookiesToAdd,
+          cookiesToDelete = deleteFancySidCookies)
   }
 
 
@@ -675,10 +779,17 @@ class EdSecurity(globals: Globals) {
   }
 
 
-  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)
+
+  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 ppt = dao.getParticipant(userId)
     throwForbiddenIf(ppt.exists(_.isGroup), "TyELGIGRP", "Cannot login as a group")  // [imp-groups]
@@ -686,41 +797,315 @@ class EdSecurity(globals: Globals) {
     val settings = dao.getWholeSiteSettings()
     val expireIdleAfterSecs = settings.expireIdleAfterMins * 60
 
-    // Note that the xsrf token is created using the non-base64 encoded cookie value.
-    val sidOk = createSessionId(siteId, userId)
     val xsrfOk = createXsrfToken()
-    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))
+
+    // 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)
     val sidCookie = urlEncodeCookie(SessionIdCookieName, sidOk.value,
       maxAgeSecs = Some(expireIdleAfterSecs))
-    val xsrfCookie = urlEncodeCookie(XsrfCookieName, xsrfOk.value,
-      maxAgeSecs = Some(expireIdleAfterSecs + XsrfAliveExtraSeconds))
+
+    COULD_OPTIMIZE // use ArrBuf
     (sidOk, xsrfOk, sidCookie::xsrfCookie::Nil)
   }
 
 
-  private val HashLength: Int = 15
-  private def secretSalt = globals.applicationSecret
+  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)
+  }
 
 
-  private def checkSessionId(siteId: SiteId, value: String, now: When, expireIdleAfterMillis: Long)
-      : SidStatus = {
+  // 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)
+    }
+
     // Example value: 88-F7sAzB0yaaX.1312629782081.1c3n0fgykm  - no, obsolete
-    if (value.length <= HashLength) return SidBadFormat
+    if (value.length <= HashLength)
+      return CheckSidResult.noSession(SidBadFormat)
+
     val (hash, dotUseridDateRandom) = value splitAt HashLength
     val realHash = hashSha1Base64UrlSafe(
-      s"$secretSalt.$siteId$dotUseridDateRandom") take HashLength
-    if (hash != realHash) return SidBadHash
-    dotUseridDateRandom.drop(1).split('.') match {
+      s"$secretSalt.${site.id}$dotUseridDateRandom") take HashLength
+
+    if (hash != realHash)
+      return CheckSidResult.noSession(SidBadHash)
+
+    val oldOkSid = dotUseridDateRandom.drop(1).split('.') match {
       case Array(userIdString, dateStr, randVal) =>
         val userId: Option[UserId] =
           if (userIdString.isEmpty) None
           else Try(userIdString.toInt).toOption orElse {
-            return SidBadFormat
+            return CheckSidResult.noSession(SidBadFormat)
           }
         val ageMillis = now.millis - dateStr.toLong
         UX; BUG; COULD; // [EXPIREIDLE] this also expires *active* sessions. Instead,
@@ -728,42 +1113,67 @@ class EdSecurity(globals: Globals) {
         // ... Need to have a SiteDao here then. And pass the Participant back to the
         // caller, so it won't have to look it up again.
         // Not urgent though — no one will notice: by default, one stays logged in 1 year [7AKR04].
-        if (ageMillis > expireIdleAfterMillis)
-          return SidExpired(
-            minutesOld = ageMillis / MillisPerMinute,
-            maxAgeMins = expireIdleAfterMillis / MillisPerMinute)
+        if (ageMillis > expireIdleAfterMillis) {
+          val expiredSid = SidExpired(
+                  minutesOld = ageMillis / MillisPerMinute,
+                  maxAgeMins = expireIdleAfterMillis / MillisPerMinute,
+                  wasForPatId = userId)
+          return CheckSidResult.noSession(expiredSid)
+        }
         SidOk(
           value = value,
           ageInMillis = ageMillis,
           userId = userId)
       case _ => SidBadFormat
     }
+
+    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)
   }
 
 
-  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.
+  @deprecated("Now", "Use the fancy session id instead.")
+  private def createSessionId(site: SiteBrief, userId: PatId): SidOk = {
     val now = globals.now()
-    val uid = "" // for now
     val useridDateRandom =
          userId +"."+
          now.millis +"."+
          (nextRandomString() take 10)
+
     // If the site id wasn't included in the hash, then an admin from site A could
     // login as admin at site B, if they have the same user id and username.
     val saltedHash = hashSha1Base64UrlSafe(
-      s"$secretSalt.$siteId.$useridDateRandom") take HashLength
-    val value = s"$saltedHash.$useridDateRandom"
+      s"$secretSalt.${site.id}.$useridDateRandom") take HashLength
 
-    checkSessionId(siteId, value, now, expireIdleAfterMillis = Long.MaxValue).asInstanceOf[SidOk]
+    val value = s"$saltedHash.$useridDateRandom"
+    SidOk(value, ageInMillis = 0, Some(userId))
   }
 
 
   // ----- Secure cookies
 
-  def SecureCookie(name: String, value: String, maxAgeSeconds: Option[Int] = None,
-        httpOnly: Boolean = false) =
+  def SecureCookie(name: St, value: St, maxAgeSeconds: Opt[i32] = None,
+        httpOnly: Bo = false): Cookie =
     Cookie(
       name,
       value,
@@ -783,6 +1193,8 @@ class EdSecurity(globals: Globals) {
     */
   private def anySameSiteCookieValue(): Option[Cookie.SameSite] = {  // [SAMESITE]
     // SameSite.None only works with https.
+    SECURITY // Remove this? No longer needed — instead, session parts 1-5,
+    // and 5 is always strict.
     if (globals.secure && globals.config.sameSiteNone) {
       Some(Cookie.SameSite.None)
     }
@@ -798,7 +1210,20 @@ class EdSecurity(globals: Globals) {
   def DiscardingSecureCookie(name: String) =
     DiscardingCookie(name, secure = globals.secure)
 
-  def DiscardingSessionCookie: DiscardingCookie = DiscardingSecureCookie(SessionIdCookieName)
+  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))
+
   // Maybe also always delete:  ImpersonationCookieName  ?
   // Well, things work fine anyway as of now (Mars 2020).
 
@@ -809,25 +1234,33 @@ class EdSecurity(globals: Globals) {
   // then it is surrounded with quotes.
   // the jQuery cookie plugin however expects an urlencoded value:
   // 2. urlEncode(value) results in these cookies being sent:
-  //    Set-Cookie: dwCoUserEmail="kajmagnus79%40gmail.com";Path=/
-  //    Set-Cookie: dwCoUserName="Kaj%20Magnus";Path=/
+  //    Set-Cookie: dwCoUserEmail="someone%40exaple.com";Path=/
+  //    Set-Cookie: dwCoUserName="space%20text";Path=/
   // No encoding results in these cookies:
-  //    Set-Cookie: dwCoUserEmail=kajmagnus79@gmail.com;Path=/
-  //    Set-Cookie: dwCoUserName="Kaj Magnus";Path=/
+  //    Set-Cookie: dwCoUserEmail=someone@example.com;Path=/
+  //    Set-Cookie: dwCoUserName="space text";Path=/
   // So it seems a % encoded string is surrounded with double quotes, by
   // javax.servlet.http.Cookie? Why? Not needed!, '%' is safe.
   // So I've modified jquery-cookie.js to remove double quotes when
   // reading cookie values.
-  private def urlEncodeCookie(name: String, value: String, maxAgeSecs: Option[Int] = None) =
+  private def urlEncodeCookie(name: St, value: St, maxAgeSecs: Opt[i32] = None,
+        httpOnly: Bo = false, sameSiteStrict: Bo = false) =
     Cookie(
       name = name,
       value = urlEncode(convertEvil(value)),  // see comment above
       maxAge = maxAgeSecs,
       path = "/",
+      // 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
       domain = None,
       secure = globals.secure,
-      sameSite = anySameSiteCookieValue(),
-      httpOnly = false)
+      sameSite =
+            // If debugging on localhost over http, does Strict work?
+            if (sameSiteStrict) Some(Cookie.SameSite.Strict)
+            else anySameSiteCookieValue(),
+      httpOnly = httpOnly)
 
 
   /** Extracts any browser id cookie from the request, or creates it if absent

programming_language: Scala
filename: JsX.scala
change_type: ModificationType.MODIFY
diff: @@ -310,6 +310,24 @@ object JsX {   RENAME // to JsonPaSe
   }
 
 
+  /// 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
+  }
+
+
   def JsUserStats(stats: UserStats, isStaffOrSelf: Boolean): JsObject = {
     val tourTipsIds: immutable.Seq[String] = stats.tourTipsSeen getOrElse Nil
     var result = Json.obj(

programming_language: Scala
filename: QueryDoController.scala
change_type: ModificationType.MODIFY
diff: @@ -81,8 +81,8 @@ class QueryDoController @Inject()(cc: ControllerComponents, edContext: EdContext
       val listQueryJsOb = parseOptJsObject(jsOb, "listQuery")
       val searchQueryJsOb = parseOptJsObject(jsOb, "searchQuery")
       // Any nested queries or actions? (E.g. for fine grained transaction control.)
-      val nestedQueries: Opt[JsArray] = parseOptJsArray(jsVal, "manyQueries")
-      val nestedActions: Opt[JsArray] = parseOptJsArray(jsVal, "doActions")
+      val nestedQueries: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "manyQueries")
+      val nestedActions: Opt[IndexedSeq[JsValue]] = parseOptJsArray(jsVal, "doActions")
 
       val anyQueryDefined =
             getQueryJsOb.isDefined || listQueryJsOb.isDefined ||

programming_language: Scala
filename: SsoAuthnController.scala
change_type: ModificationType.MODIFY
diff: @@ -104,14 +104,14 @@ class SsoAuthnController @Inject()(cc: ControllerComponents, edContext: EdContex
 
         val user = dao.getTheUser(userId)
         dao.pubSub.userIsActive(siteId, user, request.theBrowserIdData)
-        val (sid, _, sidAndXsrfCookies) = security.createSessionIdAndXsrfToken(siteId, user.id)
+        val (sid, _, sidAndXsrfCookies) =
+              security.createSessionIdAndXsrfToken(request, user.id)
 
         val response = if (request.isAjax) {
           // As of 2019-12: This is embedded comments login, when 3rd party cookies blocked. [306KUD244]
           SECURITY // a session cookie will get attached too — would be good if it could
           // be deleted server side. [serversid]
           OkSafeJson(Json.obj(
-            // Not yet weak but later. [weaksid]
             "weakSessionId" -> JsString(sid.value)))  // [NOCOOKIES]
         }
         else {
@@ -241,11 +241,10 @@ class SsoAuthnController @Inject()(cc: ControllerComponents, edContext: EdContex
           upsertUser(extUser, req, mayOnlyInsertNotUpdate = true)
 
     val (sid, _, _) =
-          security.createSessionIdAndXsrfToken(siteId, user.id)
+          security.createSessionIdAndXsrfToken(req, user.id)
 
     OkSafeJson(Json.obj(
-      // Not yet weak but later. [weaksid]  [NOCOOKIES]
-      "weakSessionId" -> JsString(sid.value)))
+      "weakSessionId" -> JsString(sid.value)))  // [NOCOOKIES]
   }
 
 

programming_language: Scala
filename: package.scala
change_type: ModificationType.MODIFY
diff: @@ -33,6 +33,82 @@ package object authn {   REFACTOR; MOVE // most of this to an object UserInfoPar
     case object LoginToAdministrate extends LoginReason(24)
   }
 
+  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)
+  }
+
+
   // Aliases for better readability.
   type JoinOrLeave = AddOrRemove
   val Join: Add.type = Add

programming_language: Scala
filename: package.scala
change_type: ModificationType.ADD
diff: @@ -0,0 +1,56 @@
+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"
+  }
+
+}

programming_language: Scala
filename: SessionController.scala
change_type: ModificationType.ADD
diff: @@ -0,0 +1,90 @@
+/**
+ * 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")
+  }
+
+}
+

programming_language: Scala
filename: SessionSiteDaoMixin.scala
change_type: ModificationType.ADD
diff: @@ -0,0 +1,165 @@
+/**
+ * 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
+  }
+
+}

programming_language: Scala
filename: head-bundle.ts
change_type: ModificationType.MODIFY
diff: @@ -289,7 +289,7 @@ if (!eds.isInEmbeddedEditor) {
 var debiki = { internal: {}, v0: { util: {}} };
 
 // Talkyard per page load session data (if we avoid cookies). [NOCOOKIES]   rename to  tyd?  see above *@
-// Maybe store this in Myself instead? Next to Myself.mySidPart1ForJs? [sess_in_me]
+// Maybe store this in Me instead? Next to Me.mySidPart1? [sess_in_me]
 // And remove typs.
 var typs: PageSession = {
   xsrfTokenIfNoCookies: _volatileData.xsrfTokenIfNoCookies,

programming_language: JavaScript
filename: login-dialog.more.ts
change_type: ModificationType.MODIFY
diff: @@ -167,7 +167,7 @@ const LoginDialog = createClassAndFactory({
         anyReturnToUrl,
         preventClose: preventClose || loginReason === LoginReason.AuthnRequiredToRead ||
             loginReason === LoginReason.LoginToAdministrate,
-        isLoggedIn: !!getSetCookie('dwCoSid'),
+        isLoggedIn: store.me.isLoggedIn,
       });
   },
 
@@ -246,6 +246,14 @@ const LoginDialog = createClassAndFactory({
       anyReturnToUrl: null,
       isLoggedIn: null,
     });
+    /* 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);
+     */
   },
 
   setChildDialog: function(childDialog) {
@@ -553,7 +561,7 @@ const LoginDialogContent = createClassAndFactory({
     if (anySsoUrl) {
       // Maybe incl username and id in __html_encoded_volatile_json__ ?
       // Not always done in login window.
-      const hasSid = getSetCookie('dwCoSid');
+      const hasSid = me_hasSid();
       const loggedInButMayNotAccess = !hasSid ? null : r.p({},
         "You're logged in but seems you cannot access this part of the site " +  // I18N
         "(if it exists). " +

programming_language: TypeScript
filename: my-menu.more.ts
change_type: ModificationType.MODIFY
diff: @@ -41,6 +41,7 @@ export const MyMenuContent = createFactory({
       dialogClassName: 'e_ByeD',
       tiny: true,
       body: t.LogOut + '?',
+      // + "[x] Log out from all devices"  — if there're other sessions as well
       primaryButtonTitle: t.YesBye,
       secondaryButonTitle: t.Cancel,
       onCloseOk: function(whichButton) {

programming_language: JavaScript
filename: user-preferences.more.ts
change_type: ModificationType.MODIFY
diff: @@ -32,6 +32,7 @@ import EmailInput = debiki2.util.EmailInput;
 const aboutPathSeg = 'about';
 const notfsPathSeg = 'notifications';
 const privacyPathSeg = 'privacy';
+const securityPathSeg = 'security';
 const uiPathSeg = 'ui';
 const accountPathSeg = 'account';  // [4JKT28TS]
 
@@ -44,6 +45,7 @@ export const UserPreferences = createFactory({
     const prefsPathSlash = pathTo(user) + SlashPrefsSlash;
     const aboutPath = prefsPathSlash + aboutPathSeg;
     const privacyPath = prefsPathSlash + privacyPathSeg;
+    const securityPath = prefsPathSlash + securityPathSeg;
     const uiPath = prefsPathSlash + uiPathSeg;
     const emailsLoginsPath = prefsPathSlash + accountPathSeg;
     const location = this.props.location;
@@ -70,6 +72,7 @@ export const UserPreferences = createFactory({
       Route({ path: '(.*)/' + aboutPathSeg, exact: true, render: () => AboutTab(childProps) }),
       Route({ path: '(.*)/' + notfsPathSeg, exact: true, render: () => NotfPrefsTab(childProps) }),
       Route({ path: '(.*)/' + privacyPathSeg, exact: true, render: () => PrivacyPrefsTab(childProps) }),
+      Route({ path: '(.*)/' + securityPathSeg, exact: true, render: () => SecurityPrefsTab(childProps) }),
       Route({ path: '(.*)/' + accountPathSeg, exact: true, render: (ps) =>
           user.isGroup
             ? AccountTabForGroup({ ...childProps, ...ps })
@@ -95,6 +98,8 @@ export const UserPreferences = createFactory({
                   to: prefsPathSlash + notfsPathSeg, className: 's_UP_Prf_Nav_NtfsL' }, t.Notifications),
               isGroupGuestOrBuiltIn ? null : LiNavLink({
                   to: privacyPath, className: 'e_UP_Prf_Nav_PrivL' }, t.upp.Privacy),
+              isGroupGuestOrBuiltIn ? null : LiNavLink({
+                  to: securityPath, className: 'e_UP_Prf_Nav_SecL' }, t.upp.Security),
               isGuestOrBuiltIn ? null : LiNavLink({
                   to: emailsLoginsPath, className: 's_UP_Prf_Nav_EmLgL' }, t.upp.Account),
               !isNormalMember ? null : LiNavLink({
@@ -785,6 +790,127 @@ const PrivacyPrefsTab = createFactory({
 });
 
 
+
+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);
+}
+
+
+
 const AccountTabForGroup = React.createFactory<any>(function(props: { member: Group, store: Store }) {
   const me: Myself = props.store.me;
   const group: Group = props.member;

programming_language: TypeScript
filename: ReactActions.ts
change_type: ModificationType.MODIFY
diff: @@ -81,8 +81,13 @@ export function loadMyself(afterwardsCallback?: () => Vo) {
   Server.loadMyself((anyMe: Me | NU, stuffForMe?: StuffForMe) => {
     // @ifdef DEBUG
     // Might happen if there was no weakSessionId, and also, no cookie.
-    dieIf(!anyMe, 'TyE4032SMH57');
+    // 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]');
     // @endif
+    // Maybe?: if (!anyMe) return;
+    // Or?: if (!anyMe) logoutClientSideOnly()
+
     const newMe = anyMe as Me;
     if (isInSomeEmbCommentsIframe()) {
       // Tell the embedded comments or embedded editor iframe that we just logged in,
@@ -140,13 +145,15 @@ export function newUserAccountCreated() {
 // Together with Server.deleteTempSessId() and Server.rememberTempSession().
 
 
+/// Not currently in use? But maybe call this one instead of
+/// Server.logoutServerAndClientSide() drectly?
 export function logout() {
   Server.logoutServerAndClientSide();
 }
 
 
 export function logoutClientSideOnly(ps: { goTo?: St, skipSend?: Bo } = {}) {
-  Server.deleteTempSessId();
+  Server.deleteTempSessId();  // [is_logging_out]
 
   ReactDispatcher.handleViewAction({
     actionType: actionTypes.Logout

programming_language: TypeScript
filename: Server.ts
change_type: ModificationType.MODIFY
diff: @@ -1153,6 +1153,27 @@ export function loginWithOneTimeSecret(oneTimeLoginSecret: string,
 }
 
 
+/// 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;
+}
+
+
 export function rememberTempSession(ps: { weakSessionId: St }) {  // [ts_authn_modl]
   const onOk = function() {};
   makeUpdNoCookiesTempSessionIdFn(onOk)(ps);
@@ -1181,6 +1202,7 @@ function makeUpdNoCookiesTempSessionIdFn<R>(  // [ts_authn_modl]
 
 
 export function deleteTempSessId() {  // [ts_authn_modl]
+  // Need not delete store.me.mySidPart1 — we'll reload the page anyway. [is_logging_out]
   const mainWin = getMainWin();
   const typs: PageSession = mainWin.typs;
   delete typs.weakSessionId;
@@ -1188,6 +1210,7 @@ export function deleteTempSessId() {  // [ts_authn_modl]
   try {
     // Can this throw?
     getSetCookie('dwCoSid', null);
+    getSetCookie('TyCoSid123', null);
   }
   catch (ex) {
     // Just in case.
@@ -1283,10 +1306,9 @@ export function listCompleteUsers(whichUsers, success: (users: UserInclDetailsWi
 type UserAcctRespHandler = (response: UserAccountResponse) => void;
 
 
-export function loadEmailAddressesAndLoginMethods(userId: UserId, success: UserAcctRespHandler) {
-  get(`/-/load-email-addrs-login-methods?userId=${userId}`, response => {
-    success(response);
-  });
+export function loadEmailAddressesAndLoginMethods(userId: UserId, onOk: UserAcctRespHandler,
+          onErr?: (resp: A) => V) {
+  get(`/-/load-email-addrs-login-methods?userId=${userId}`, onOk, onErr);
 }
 
 
@@ -1303,8 +1325,9 @@ export function resendEmailAddrVerifEmail(userId: UserId, emailAddress: string)
   }, { userId, emailAddress });
 }
 
-export function addEmailAddresses(userId: UserId, emailAddress: string, success: UserAcctRespHandler) {
-  postJsonSuccess('/-/add-email-address', success, { userId, emailAddress });
+export function addEmailAddresses(userId: UserId, emailAddress: St, onOk: UserAcctRespHandler,
+        onErr: (resp: A) => V) {
+  postJsonSuccess('/-/add-email-address', onOk, onErr, { userId, emailAddress });
 }
 
 
@@ -1350,8 +1373,9 @@ export function editMember(userId: UserId, doWhat: EditMemberAction, success: ()
 }
 
 
-export function suspendUser(userId: UserId, numDays: number, reason: string, success: () => void) {
-  postJsonSuccess('/-/suspend-user', success, {
+export function suspendUser(userId: UserId, numDays: Nr, reason: St, onOk: () => V,
+        onErr: (resp: A) => V) {
+  postJsonSuccess('/-/suspend-user', onOk, onErr, {
     userId: userId,
     numDays: numDays,
     reason: reason
@@ -1491,6 +1515,20 @@ export function loadMyself(onOk: (me: Me | NU, stuffForMe?: StuffForMe) => Vo) {
         function (resp: { me?: Me, stuffForMe?: StuffForMe }) {
     onOk(resp.me, resp.stuffForMe);
   });
+    // 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);
 }
 
 

programming_language: TypeScript
filename: if-in-iframe.ts
change_type: ModificationType.MODIFY
diff: @@ -111,6 +111,7 @@ function onMessage(event) {
         mainWin.typs.weakSessionId = eventData.weakSessionId;
         typs.weakSessionId = eventData.weakSessionId;
         // This sends 'justLoggedIn' to other iframes, so they'll get updated too.
+        // Reply 'failedToLogin' if login failed  [forget_sid12]
         ReactActions.loadMyself();
       }
       else {
@@ -124,9 +125,9 @@ function onMessage(event) {
       // makeUpdNoCookiesTempSessionIdFn() or in the 'case:' just above, lets check:
       // @ifdef DEBUG
       const mainWin: MainWin = getMainWin();
-      if (!mainWin.typs.weakSessionId && !getSetCookie('dwCoSid')) {
+      if (!me_hasSid()) {
         logAndDebugDie(`justLoggedIn but not logged in? ` +
-            `No cookie, no typs.weakSessionId. ` +
+            `No session cookie, no typs.weakSessionId. ` +
             `This frame name: ${window.name}, ` +
             `main frame name: ${mainWin.name}, ` +
             `this is main frame: ${window === mainWin}, ` +
@@ -142,6 +143,9 @@ function onMessage(event) {
       // @endif
       ReactActions.setNewMe(eventData.user, eventData.stuffForMe);
       break;
+    case 'logoutServerAndClientSide':
+      Server.logoutServerAndClientSide();
+      break;
     case 'logoutClientSideOnly':
       // Sent from the comments iframe one logged out in, to the editor iframe
       // and other comments iframes.

programming_language: TypeScript
filename: login-if-needed.ts
change_type: ModificationType.MODIFY
diff: @@ -288,6 +288,7 @@ export function continueAfterLogin(anyReturnToUrl?: string) {
             "continue in?  [TyEOPNRGONE]", { mayClose: false });
       }
       else {
+        // We've remembered any weakSessionId already, see [5028KTDN306].
         window.opener['debiki'].internal.handleLoginResponse({ status: 'LoginOk' });
         // Close this popup window — we'll continue in the main window.
         close();

programming_language: TypeScript
filename: me-getters.ts
change_type: ModificationType.MODIFY
diff: @@ -30,6 +30,16 @@
 //------------------------------------------------------------------------------
 
 
+/// 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
+}
+
+
 export function me_isAuthenticated(me: Myself): boolean {
   return me.id && me.id >= MinMemberId;
 }

programming_language: JavaScript
filename: model.ts
change_type: ModificationType.MODIFY
diff: @@ -35,7 +35,7 @@ interface PageSession  {
   //
   // ADD_TO_DOCS
   //
-  weakSessionId?: St;
+  weakSessionId?: St;  // RENAME to sid12Maybe3 ?
 
   // If the session is for an embedded comments iframe. REMOVE incl in sid instead, somehow.
   sessType?: SessionType.AutoTokenSiteCustomSso;
@@ -73,6 +73,10 @@ interface CheckboxEvent {
   };
 }
 
+/// 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);
+
 type ValueOk<T> = {
   value?: T;
   isOk?: boolean;
@@ -480,6 +484,9 @@ interface OwnPageNotfPrefs {  // RENAME to MembersPageNotfPrefs?
 type Myself = Me; // renaming to Me
 interface Me extends OwnPageNotfPrefs {   // + extends Pat?
   dbgSrc?: string;
+  // 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;
   id?: UserId;
   isStranger?: Bo;
   // missing?: isGuest?: Bo
@@ -565,6 +572,17 @@ interface StuffForMe {
 }
 
 
+interface Session {
+  patId: PatId;
+  createdAt: WhenMs;
+  version: Nr,
+  startHeaders: { [name: St]: St };
+  part1: St;
+  deletedAt?: WhenMs;
+  expiredAt?: WhenMs;
+}
+
+
 interface GroupPerms {
   maxUploadBytes?: Nr;
   allowedUplExts?: St;
@@ -2398,6 +2416,16 @@ interface LoadPatVvbResponse {
 }
 
 
+interface ListSessionsResponse {
+  sessions: Session[];
+}
+
+
+interface TerminateSessionsResponse {
+  terminatedSessions: Session[];
+}
+
+
 // COULD also load info about whether the user may apply and approve the edits.
 interface LoadDraftAndTextResponse {
   pageId: PageId;

programming_language: TypeScript
filename: oop-methods.ts
change_type: ModificationType.MODIFY
diff: @@ -750,8 +750,12 @@ export function settings_selectTopicType(settings: SettingsVisibleClientSide, me
 
 
 export function store_isFeatFlagOn(store: Store, featureFlag: St): Bo {
-  return _.includes(store.siteFeatureFlags, featureFlag) ||
+  const offFlag = '0' + featureFlag;
+  const isOn = _.includes(store.siteFeatureFlags, featureFlag) ||
          _.includes(store.serverFeatureFlags, featureFlag);
+  const isOff = _.includes(store.siteFeatureFlags, offFlag) ||
+         _.includes(store.serverFeatureFlags, offFlag);
+  return isOn && !isOff;   // [ff_on_off]
 }
 
 

programming_language: TypeScript
filename: prelude.ts
change_type: ModificationType.MODIFY
diff: @@ -43,7 +43,7 @@ declare function smoothScroll(elem: Element, x: number, y: number,
     durationMs?: number, onDone?: () => void);
 
 // Defined in client/third-party/get-set-cookie.js.
-declare function getSetCookie(cookieName: string, value?: string, options?: any): string;
+declare function getSetCookie(cookieName: St, value?: St, options?: Ay): St | Nl;
 
 
 // backw compat, later, do once per file instead (don't want a global 'r').
@@ -517,6 +517,7 @@ export function firstDefinedOf(x, y, z?) {
 
 
 /** Like _.groupBy but keeps just one value per key.
+    RENAME to arr_groupByKeepOne ?
   */
 export function groupByKeepOne<V>(vs: V[], fn: (v: V) => number): { [key: number]: V } {
   const manyById: { [key: number]: V[] } = _.groupBy(vs, fn);
@@ -525,8 +526,47 @@ export function groupByKeepOne<V>(vs: V[], fn: (v: V) => number): { [key: number
 }
 
 
+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;
+  }
+}
+
+
 // Finds and replaces (in-place) the first item with item.id = replacement.id.
 // Dies, if there's not matching item.
+// REFACTOR use arr_replaceOneInPl & x => x.id  instead.
+// + add an IfNotFound.ThenDie/ThenAppend/ThenIgnore param? Default to ThenDie?
 export function replaceById(itemsWithId: any[], replacement) {
   // @ifdef DEBUG
   dieIf(isNullOrUndefined(replacement.id), 'EdE4GJTH02');

programming_language: TypeScript
filename: slim-bundle.d.ts
change_type: ModificationType.MODIFY
diff: @@ -189,6 +189,14 @@ declare namespace debiki2 {
   var createClassAndFactory: any; // don't use — I'm renaming to createFactory
   function createFactory<P, S = any>(compSpec: React.ComponentSpec<P, S>): React.Factory<any>;
 
+  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 replaceById(itemsWithId: any[], replacement);
   function deleteById(itemsWithId: any[], id);
@@ -314,6 +322,7 @@ declare namespace debiki2 {
   function page_isPrivateGroup(pageRole: PageRole): boolean;
   function pageRole_iconClass(pageRole: PageRole): string;
 
+  function me_hasSid(): Bo;
   function me_uiPrefs(me: Myself): UiPrefs;
   function pp_snoozeLeftMs(me: Myself): number;
   function member_isBuiltIn(member: Member): boolean;

programming_language: TypeScript
filename: start-page.ts
change_type: ModificationType.MODIFY
diff: @@ -18,6 +18,7 @@
 /// <reference path="prelude.ts" />
 /// <reference path="init-all-react-roots.ts" />
 /// <reference path="link-previews.ts" />
+/// <reference path="oop-methods.ts" />
 
 
 const d = { i: debiki.internal };
@@ -25,6 +26,7 @@ const d = { i: debiki.internal };
 const logM = debiki2.logM;
 const logE = debiki2.logE;
 
+
 let pageStarted: undefined | true;
 const scriptLoadDoneCallbacks = [];
 debiki.scriptLoad = {  // RENAME to tyd.afterStarted(...) ?
@@ -62,16 +64,50 @@ debiki.nowServiceWorkerIsRightVersion = function() {
 const allPostsNotTitleSelector = '.debiki .dw-p:not(.dw-p-ttl)';
 
 
+
 function handleLoginInOtherBrowserTab() {
-  const currentUser = debiki2.ReactStore.getMe();
+  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:
+  // ------------------------------------------
   const sessionId = getSetCookie('dwCoSid');
-  if (currentUser.isLoggedIn) {
+  if (me.isLoggedIn) {
     if (sessionId) {
       // Session id example: (parts: hash, user id, name, login time, random value)
       // 'Y1pBlH7vY4JW9A.11.Magnus.1316266102779.15gl0p4xf7'
       const parts = sessionId.split('.');
       const newUserIdString = parts[1];
-      if (currentUser.userId !== parseInt(newUserIdString)) {
+      if (me.id !== parseInt(newUserIdString)) {
         // We've logged in as another user in another browser tab.
         debiki2.ReactActions.loadMyself();
       }
@@ -94,6 +130,7 @@ function handleLoginInOtherBrowserTab() {
 }
 
 
+
 function registerEventHandlersFireLoginOut() {
   // If the user switches browser tab, s/he might logout and login
   // in another tab. That'd invalidate all xsrf tokens on this page,

programming_language: JavaScript
filename: translations.d.ts
change_type: ModificationType.MODIFY
diff: @@ -668,6 +668,7 @@ interface TalkyardTranslations {
     DraftsEtc: string;
     About: string;
     Privacy: string;
+    Security: string;
     Account: string;
     Interface: string;
 

programming_language: TypeScript
filename: blog-comments.ts
change_type: ModificationType.MODIFY
diff: @@ -221,16 +221,14 @@ mutationObserver.observe(document.body, { subtree: true, childList: true });
 
 
 
-// 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
+// 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]
 //
-// [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.
-
 let someStorage: Storage | undefined;
 let tempObjStorage;
 
@@ -238,6 +236,7 @@ let tempObjStorage;
 // so need try-catch.
 try {
   someStorage = localStorage;
+  window.addEventListener('storage', onLocalStorageChanged);
 }
 catch {
 }
@@ -264,6 +263,24 @@ const theStorage: Storage = someStorage || {
 } as Storage;
 
 
+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;
+  } */
+}
+
 
 addEventListener('scroll', messageCommentsIframeNewWinTopSize);
 addEventListener('message', onMessage, false);
@@ -863,7 +880,11 @@ function onMessage(event) {
         let sessionStr;
         try {
           sessionStr = theStorage.getItem('talkyardSession');
+          // 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]
           theStorage.removeItem('talkyardSession');  // see above (3548236)
+          curSessItemInStorage = null;
         }
         catch (ex) {
           logW(`Error getting 'talkyardSession' from theStorage [TyEGETWKSID]`, ex);
@@ -936,6 +957,16 @@ function onMessage(event) {
     case 'justLoggedIn':
       const u = eventData.user || {};
       logM(`Logged in as ${u.username || u.fullName} in iframe`);
+      // @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
       if (eventData.rememberEmbSess) try {
         const item = {
           pubSiteId: eventData.pubSiteId,
@@ -960,7 +991,10 @@ function onMessage(event) {
           // This re-inserts our session (3548236), if we just sent a 'resumeWeakSession'
           // message to the iframe and then removed it from theStorage  — because
           // the comments iframe sends back 'justLoggedIn', after having logged in.
-          theStorage.setItem('talkyardSession', JSON.stringify(item));
+          curSessItemInStorage = JSON.stringify(item);
+          theStorage.setItem('talkyardSession', curSessItemInStorage);
+
+          // ! Need a 'failedToLogin' if login failed  [forget_sid12]
         }
       }
       catch (ex) {
@@ -973,6 +1007,7 @@ function onMessage(event) {
       logM(`Logged out`);
       try {
         theStorage.removeItem('talkyardSession');
+        curSessItemInStorage = null;
       }
       catch (ex) {
         logW(`Error removing 'talkyardSession' from  theStorage [TyERMWKSID]`, ex);

programming_language: TypeScript
filename: types-and-const-enums.ts
change_type: ModificationType.MODIFY
diff: @@ -33,12 +33,14 @@
 //
 type U = undefined;
 type Vo = void; // but not 'V' because that's sometimes a 'V'alue template param.
+type V  = void; // ... hmm but really really not often. Let's use 'Va' for such templ params?
 type Nl = null; // but not 'Nu' because that could be "Number".
 type N = null;  // ... hmm I don't think so. One letter U and N for undef & null,
                 // ... and 2 letters for "real" datatypes — easy to remember?
                 // "Nl" is actually a bit hard to read! (the 'l')
 type Ay = any;  // but not 'An' because that sounds like 'an'. Upd: Skip this. Use 'any',
                 // should try to long term do away with all 'any' anyway.
+type A = any;   // Why not A! Let's use 'A'.
 type Bo = boolean;
 type Nr = number;
 type St = string;
@@ -132,8 +134,6 @@ const enum SessionType {
   SiteCustomOSaml = 4,
   SiteCustomSso = 5,
 
-  ApiSecret =  // instead of SidOk("_api_secret_", ..)
-
   EmailSecret = 8,
   BackupCode = 9,
 

programming_language: Scala
filename: routes
change_type: ModificationType.MODIFY
diff: @@ -163,6 +163,10 @@ POST  /-/remove-email-address   controllers.UserController.removeUserEmail
 GET   /-/confirm-email-address  controllers.UserController.confirmOneMoreEmailAddress(confirmationEmailId)
 POST  /-/resend-email-addr-verif-email controllers.UserController.resendEmailAddrVerifEmail
 
+GET   /-/list-sessions          talkyard.server.sess.SessionController.listSessions(patId: Int)
+POST  /-/terminate-sessions     talkyard.server.sess.SessionController.terminateSessions
+# Maybe move /-/logout to SessionController?
+
 GET   /-/unsubscribe            controllers.UnsubscriptionController.showForm(emailId)
 POST  /-/unsubscribe            controllers.UnsubscriptionController.handleForm(emailId)
 GET   /-/unsubscribed           controllers.UnsubscriptionController.showHasBeenUnsubscribed()

programming_language: Markdown
filename: maybe-do-later.txt
change_type: ModificationType.MODIFY
diff: @@ -92,6 +92,9 @@ Privacy Badger doesn't like transient session cookies, currently.
 So maybe change from csrf Double Submit Cookie, to Encrypted Token Pattern?
 https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Encrypted_Token_Patternp
 
+[gdpr]
+New table: sessions_t, impl fn deletePersonalDataFromOldSessions().  [sess_deleted_ix]
+
 [pps]
 Rename User to Participant and User to Member.  Participant = Guest or Member, Member = User or Group.
 Rename variables named like "member" to "user", "memberOrGroup" to "member", "user" to "participant"
@@ -558,8 +561,11 @@ Server side sesison ids, maybe in Redis? So can log ppl out, without having acce
 to their browsers to delete session cookies.
 (Also see [offline-first] above: need two cookies.)
 Include session type (e.g. pwd, oauth, email secret, sso, emb sso) in sid:s,
-see: SessionType.AutoTokenSiteCustomSso, and: SidOk("_api_secret_".
-Also see: [weaksid].
+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]
 
 [sess_in_me]
 Store xsrf token and any sid-part-1 in Me, instead of win.typs?
@@ -867,6 +873,15 @@ About how to write docs:
 Problems that happen only on iOS Safari (and Chrome, but all browsers are basically
 Safari on iOS). When works elsewhere, this might be bugs in iOS Safari.
 
+[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/
 
 [default_plugins]
 KaTeX for Maths (VSCode supports KaTeX, and Docusaurus too:

programming_language: Markdown
filename: naming-notes.md
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,7 @@ Talkyard Naming Notes
 `ttt` means Test the test. That is, some [assertion in a test] that doesn't test
 the real Talkyard code — instead, it tests the test code. (There's no `tttt`)
 
+`reidr` means "Re-Enter Iframe in case you just Debug-Reloaded the page".
 
 ### People
 

programming_language: Markdown
filename: tests-map.txt
change_type: ModificationType.MODIFY
diff: @@ -239,6 +239,34 @@ impersonate:
 different users -
   login, logout, switch user:  almost all tests
 
+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
+
+
 navigation:
   from anywhere, to anywhere:
           - navigation-as-stranger.test.ts  TyT5WKAB024
@@ -251,6 +279,8 @@ navigation:
             - tags-basic.2br  TyTE2ETAGSBSC.TyTNAVUSR2PG
   from user profile posts list to page:
             - badges-basic.2br  TyTE2EBADGESBSC.TyTNAVUSR2PO
+  from page to one's active sessions list:
+            - d.sessions-logout-elsewhere.4br  TyTESESLGOSELF
 
 profile page, user:
   upload photo:  (8PK2)
@@ -354,6 +384,8 @@ security:
             - embcom.comment-counts.2br.cors.test.ts   TyTE2ECOMCOUNTS
             - embcom.manyframes.comment-counts.2br.cors  TyTE2EMNYFRCOMCNTS
 
+deleted users:  [.del_susp_usr]
+            - SEC_TEST_MISSING
 suspended users:
   gets no email notfs,
   websocket gets disconnected,
@@ -1010,6 +1042,9 @@ admin:
   members tab:
     really many members:
           - many-users-mention-list-join-group.2browsers.test.ts   TESTS_MISSING  TyT60295KTDT
+  terminate someone else's sessions:
+            - d.sessions-staff-logout-others.4br  TyTESESLGOOTR
+
 
 moderation,
 approve before,
@@ -1328,6 +1363,11 @@ live notifications -
           - promote-demote-by-staff-join-leave-chats.2br.test.ts  TyTE2E5H3GFRVK
   disconnected from chat, if removed from chat:
           - SEC_TESTS_MISSING  TyT6P03MRKD
+  no more WebSocket messages,
+    if account deleted,
+    or if suspended,
+    or session terminated:
+          - SEC_TESTS_MISSING  TyTE0MOREWSMSG
   presence:
             - tags-badges-not-missing.2br  TyTETAGS0MISNG.TyTPRESENCE01
             - TESTS_MISSING  could add more, also for going offline

programming_language: Markdown
filename: tyd-e2e-tests.ts
change_type: ModificationType.MODIFY
diff: @@ -245,7 +245,7 @@ function runE2eTests(): U | ExitCode | 'TestsCancelled' {
 
   const skipAlways = ['!UNIMPL', '!-impl.', '!imp-exp-imp-exp-site'];
   const skipEmbAndAlways = ['!embcom', '!embedded-', '!embforum.', ...skipAlways]
-  const skip2And3Browsers = ['!.2br', '!.3br'];
+  const skip2And3Browsers = ['!.2br', '!.3br', '!.4br'];  // and skip 4 as well
 
 
   // ----- 1 browser
@@ -293,6 +293,19 @@ function runE2eTests(): U | ExitCode | 'TestsCancelled' {
   });
 
 
+  // ----- 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
+  });
+
+
   // ----- 1 browser, embedded comments
 
   const skip23BrAndUnusualEmb = ['!b3c.', '!gatsby', '!embforum.',

programming_language: TypeScript
filename: run-e2e-tests.sh
change_type: ModificationType.MODIFY
diff: @@ -310,6 +310,9 @@ function runAllE2eTests {
   $r s/wdio --only navigation-as-stranger $args
   # Also:  embedded-comments-navigation-as-guest  further below.
 
+  $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
+
   $r s/wdio --only hide-unhide-tips.2br $args
   $r s/wdio-7 --only show-admin-notices.2br --cd -i $args
 
@@ -647,6 +650,9 @@ function runAllE2eTests {
   $r s/wdio-7     --only embcom.manyframes.js-api.2br --cd -i $args
   $r s/wdio-7     --only embcom.manyframes.comment-counts.2br.cors --cd -i $args
 
+  # Stealing an embedded session id
+  $r s/wdio-7     --only embcom.sessions-emb-sess-cannot-moderate.3br --cd -i $args
+
 
 
   if [ -n "$http_server_pid" ]; then

programming_language: Shell
filename: UserStatsAppSpec.scala
change_type: ModificationType.MODIFY
diff: @@ -123,6 +123,10 @@ class UserStatsAppSpec extends DaoAppSuite() {
     "... logs out, last-seen stats get updated" in {
       playTimeMillis(1000)
       dao.logout(member1, bumpLastSeen = true)
+
+      // Maybe better end any sessions — although aren't any.
+      dao.terminateSessions(forPatId = member1.id, all = true)
+
       val stats = loadUserStats(member1.id)(dao)
       stats mustBe initialStats.copy(lastSeenAt = currentTime)
     }

programming_language: Scala
filename: d.sessions-logout-elsewhere.4br.e2e.ts
change_type: ModificationType.ADD
diff: @@ -0,0 +1,243 @@
+/// <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);
+  });
+
+});

programming_language: TypeScript
filename: d.sessions-staff-logout-others.4br.e2e.ts
change_type: ModificationType.ADD
diff: @@ -0,0 +1,299 @@
+/// <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
+  });
+  
+
+});
+

programming_language: TypeScript
filename: embcom.expimpjson.import-tests-impl.ts
change_type: ModificationType.MODIFY
diff: @@ -215,9 +215,15 @@ function constructEmbCommentsImportTest(testName: string, variants: {
         await owensBrowser.waitForExist('.e_RstrDne');
       });
 
-      it(`Owen remains logged in?  Or needs to login again?`, async () => {
+      it(`Owen reloads the page, as per the instructions ...`, async () => {
         await owensBrowser.refresh();
-        // Hmm, currently:
+      });
+
+      it(`... needs to log in again — sessions_t curently not exported`, async () => {
+        await owensBrowser.loginDialog.loginWithPassword(owen);
+      });
+
+      it(`... he's Owen again`, async () => {
         await owensBrowser.topbar.assertMyUsernameMatches(owen.username);
       });
     }

programming_language: TypeScript
filename: embcom.sessions-emb-sess-cannot-moderate.3br.e2e.ts
change_type: ModificationType.ADD
diff: @@ -0,0 +1,332 @@
+/// <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 () => {
+  });
+  */
+
+});
+

programming_language: TypeScript
filename: test-types.ts
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,7 @@ declare global {
   const wdioBrowserA: WebdriverIOAsync.MultiRemoteBrowser;
   const wdioBrowserB: WebdriverIOAsync.MultiRemoteBrowser | U;
   const wdioBrowserC: WebdriverIOAsync.MultiRemoteBrowser | U;
+  const wdioBrowserD: WebdriverIOAsync.MultiRemoteBrowser | U;
 
   type Sel = St // selector
   type SelOrEl = St | WebdriverIO.Element;
@@ -32,7 +33,7 @@ export const enum IsWhere {
 
   EmbFirst = 3,
   EmbeddingPage = 3,
-  EmbCommentsIframe = 4,
+  EmbCommentsIframe = 4,  // hmm could add an enum value for EmbSessionIframe
   EmbEditorIframe = 5,
   EmbLast = 5,
 

programming_language: TypeScript
filename: test-types2.ts
change_type: ModificationType.MODIFY
diff: @@ -806,6 +806,16 @@ interface EmailMatchResult {
 }
 
 
+export type ServerSays = ServerSaysOk | ServerSaysError;
+
+export interface ServerSaysOk {
+  serverSaysOk: A;
+}
+
+export interface  ServerSaysError {
+  serverSaysError: A;
+}
+
 // Right now, constraints like >= 1 aren't supported in Typescript, but this works, and, in test
 // cases, probably won't ever need larger numbers?
 type IntAtLeastOne = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19;

programming_language: TypeScript
filename: settings-exp-def.ts
change_type: ModificationType.MODIFY
diff: @@ -96,6 +96,12 @@ if (args['2br'] || args['2browsers']) {
 if (args['3br'] || args['3browsers']) {
   settings.numBrowsers = 3;
 }
+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]`);
+}
 
 if (args.cd || args.chromedrier) {
   settings.useChromedriver = true;

programming_language: TypeScript
filename: ty-e2e-test-browser.ts
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,6 @@
 import * as _ from 'lodash';
 import { IsWhere, isWhere_isInIframe } from '../test-types';
+import { ServerSays } from '../test-types2';
 import { SiteType, NewSiteOwnerType } from '../test-constants';
 
 
@@ -130,6 +131,16 @@ interface WaitAndClickPs extends WaitPs {
 type WaitForClickableResult = 'Clickable' | 'NotClickable';
 type ClickResult = 'Clicked' | 'CouldNotClick';
 
+interface SidFieldsParts {
+  sidFieldParts12Maybe3?: St;
+}
+
+interface SidParts extends SidFieldsParts {
+  sidCookiePart123?: St;
+  sidCookiePart4?: St;
+  sidCookiePart5?: St;
+}
+
 
 function isBlank(x: string): boolean {
   return _.isEmpty(x) || !x.trim();
@@ -267,7 +278,8 @@ export class TyE2eTestBrowser {
   #br: WebdriverIOAsync.Browser;
   #name: St;
 
-  constructor(aWdioBrowser: WebdriverIOAsync.Browser, name: 'brA' | 'brB' | 'brC' | 'brAll') {
+  constructor(aWdioBrowser: WebdriverIOAsync.Browser,
+          name: 'brA' | 'brB' | 'brC' | 'brD' | 'brAll') {
     dieIf(!aWdioBrowser?.getPageSource,
         `Error creating Wdio browser '${name}', this is not a browser: ${
                 JSON.stringify(aWdioBrowser)}  [TyE2E7J02SAD35]`
@@ -410,7 +422,7 @@ export class TyE2eTestBrowser {
       return await this.#br.execute.apply(this.#br, arguments);
     }
 
-    async executeAsync<T>(script: ((...args: any[]) => T), ...args: any[]): Pr<T> {
+    async executeAsync<T>(script: ((...args: any[]) => V), ...args: any[]): Pr<T> {
       return await this.#br.executeAsync.apply(this.#br, arguments);
     }
 
@@ -768,6 +780,16 @@ export class TyE2eTestBrowser {
       },
     }
 
+    user = {
+      genDownloadPersonalDataUrl: (userId: PatId): St => {
+        return '/-/download-personal-data?userId=' + userId;
+      },
+
+      genDownloadPersonalContentUrl: (userId: PatId): St => {
+        return '/-/download-my-content?authorId=' + userId;
+      },
+    }
+
 
 
     newSite = {
@@ -1189,7 +1211,8 @@ export class TyE2eTestBrowser {
 
 
     async switchToEmbeddedCommentsIrame(ps: {
-            waitForContent?: false, discId?: St, theresOnlyOne?: true } = {}) {
+            waitForContent?: false, discId?: St, theresOnlyOne?: true,
+            theSessionIdIframe?: true } = {}) {
       if (ps.discId) {
         this.useCommentsIframe({ discussionId: ps.discId });
       }
@@ -1200,7 +1223,10 @@ export class TyE2eTestBrowser {
       // Let's wait for the editor iframe, so Reply buttons etc will work.
       await this.waitForExist('iframe#ed-embedded-editor');
       let commentsIframeSelector = '';
-      if (this.#useCommentsIframe?.discussionId) {
+      if (ps.theSessionIdIframe) {
+        commentsIframeSelector = 'iframe#talkyard-session';
+      }
+      else if (this.#useCommentsIframe?.discussionId) {
         commentsIframeSelector =
                 `.talkyard-comments[data-discussion-id="${
                         this.#useCommentsIframe.discussionId}"] iframe`;
@@ -1732,8 +1758,8 @@ export class TyE2eTestBrowser {
     }
 
 
-    async waitAndClickLast(selector: St): Pr<ClickResult> {
-      return await this.waitAndClickNth(selector, -1);
+    async waitAndClickLast(selector: St, ps: { needScroll?: Bo } = {}): Pr<ClickResult> {
+      return await this.waitAndClickNth(selector, -1, ps);
     }
 
 
@@ -1778,9 +1804,9 @@ export class TyE2eTestBrowser {
 
     // For one this.#br at a time only.
     // n starts on 1 not 0. -1 clicks the last, -2 the last but one etc.
-    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,
+    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,
           `n = ${n} !== 1, won't scroll into view before trying to click, maybe will miss:  ${selector} [05YKTDTH4]`);
 
       // Currently always throws if couldn't click — timeoutIsFine isn't set.
@@ -1788,7 +1814,8 @@ export class TyE2eTestBrowser {
         return 'CouldNotClick';
 
       const elems = await this.$$(selector);
-      tyAssert.ok(elems.length >= n, `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
+      tyAssert.ok(elems.length >= Math.abs(n),
+            `Elem ${n} missing: Only ${elems.length} elems match: ${selector}`);
       const index = n > 0
           ? n - 1
           : elems.length - (-n); // count from the end
@@ -7159,9 +7186,11 @@ export class TyE2eTestBrowser {
         await this.waitUntilLoadingOverlayGone();
       },
 
-      switchToInvites: async () => {
+      switchToInvites: async (ps: { willFail?: true } = {}) => {
         await this.waitAndClick('.e_InvTabB');
-        await this.invitedUsersList.waitUntilLoaded();
+        if (!ps.willFail) {
+          await this.invitedUsersList.waitUntilLoaded();
+        }
       },
 
       waitForTabsVisible: async () => {
@@ -7169,6 +7198,23 @@ export class TyE2eTestBrowser {
         await this.waitForVisible('.e_UP_ActivityB');
       },
 
+      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();
+        },
+      },
+
       isInvitesTabVisible: async (): Pr<Bo> => {
         await this.userProfilePage.waitForTabsVisible();
         return await this.isVisible('.e_InvTabB');
@@ -7486,8 +7532,33 @@ export class TyE2eTestBrowser {
           await this.userProfilePage._goHere(username, ps, '/preferences');
         },
 
-        switchToEmailsLogins: async () => {  // RENAME to tabToAccount
+        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)
           await this.waitAndClick('.s_UP_Prf_Nav_EmLgL');
+          // Break out fn:  waitForLoaded();  ?
           if ((await this.urlPath()).startsWith(c.UsersUrlPrefix)) {
             // Wait for user emails loaded.
             await this.waitForVisible('.s_UP_EmLg_EmL');
@@ -7592,6 +7663,55 @@ export class TyE2eTestBrowser {
           },
         },
 
+        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');
+          },
+        },
+
         emailsLogins: {   // RENAME to `account`
           goHere: async (username: St, ps: { isGroup?: true, origin?: St } = {}) => {
             await this.userProfilePage._goHere(username, ps, '/preferences/account');
@@ -7601,6 +7721,17 @@ export class TyE2eTestBrowser {
             return await this.waitAndGetVisibleText('.s_UP_EmLg_EmL_It_Em');
           },
 
+          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;
+          },
+
           waitUntilEmailAddressListed: async (addrRegexStr: St,
                   opts: { shallBeVerified?: Bo } = {}) => {
             const verified = opts.shallBeVerified ? '.e_EmVerfd' : (
@@ -9663,4 +9794,168 @@ export class TyE2eTestBrowser {
       },
     }},
   }
+
+
+  /// 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;
+    },
+  };
+
 }

programming_language: TypeScript
filename: wdio.conf.ts
change_type: ModificationType.MODIFY
diff: @@ -740,11 +740,13 @@ const browserName = config.capabilities[0].browserName;
 // 2br and 3br is how many browsers the tests need — 2 or 3 browsers, default 1.
 const onlyAndSpec = (settings.only || '') + ((settings as any).spec || '');
 const needsNumBrowsers =
+    onlyAndSpec.indexOf('4br') >= 0 || settings.numBrowsers >= 4
+        ? 4 : (
     onlyAndSpec.indexOf('3br') >= 0 || settings.numBrowsers >= 3
-        ? 3
-        : (onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
-            ? 2
-            : 1);
+        ? 3 : (
+    onlyAndSpec.indexOf('2br') >= 0 || settings.numBrowsers === 2
+        ? 2
+        : 1));
 
 if (needsNumBrowsers >= 2) {
   const theCaps = config.capabilities[0];
@@ -767,6 +769,12 @@ if (needsNumBrowsers >= 2) {
     };
   };
 
+  if (needsNumBrowsers >= 4) {
+    config.capabilities.wdioBrowserD = {
+      capabilities: { ...theCaps }
+    };
+  };
+
   console.log(`I'll start ${needsNumBrowsers}${maybeInvisible} ${browserName} browsers.`);
 }
 else {

programming_language: TypeScript
filename: admin-user-suspend.2browsers.test.ts
change_type: ModificationType.MODIFY
diff: @@ -104,6 +104,9 @@ describe("admin-user-suspend [TyT5GKQSG2]", function() {
     mariasBrowser.editor.cancel();
   });
 
+  // TESTS_MISSING: should not get any more live updates via WebSocket  TyTE0MOREWSMSG
+  // Or is that better done in a separate e2e test file?
+
   it("... Now, she cannot login (was logged out by the server)", function() {
     mariasBrowser.refresh();
     mariasBrowser.acceptAnyAlert(2);  // for some reason, up to 2 alerts

programming_language: TypeScript
filename: sso-test.2browsers.test.ts
change_type: ModificationType.MODIFY
diff: @@ -126,6 +126,11 @@ describe("sso-test  TyT4ABKRW0268", () => {
 
   it("... he logs out", () => {
     owensBrowser.deleteCookie('dwCoSid');
+    // 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
     owensBrowser.refresh();
   });
 

programming_language: TypeScript
filename: i18n.ts
change_type: ModificationType.MODIFY
diff: @@ -783,6 +783,7 @@ var t_en_US: TalkyardTranslations = t = {
     DraftsEtc: "Drafts etc",
     About: "About",
     Privacy: "Privacy",
+    Security: "Security",
     Account: "Account",
     Interface: "Interface",
 

programming_language: CoffeeScript
repo_url: https://github.com/debiki/talkyard
commit_details: repo_url: https://github.com/debiki/talkyard
author: Kaj Magnus Lindberg
author_date: 2021-12-01 12:32:34+01:00
msg: Use fancy sids, not silly sids, by default.
repository: repo_url: https://github.com/debiki/talkyard
repo_name: debiki/talkyard
description: A community discussion platform: Brings together the main features from StackOverflow, Slack, Discourse, Reddit, and Disqus blog comments.
repo_language: TypeScript
owner: debiki
file_changes: filename: PlainApiActions.scala
change_type: ModificationType.MODIFY
diff: @@ -549,10 +549,8 @@ class PlainApiActions(
         // 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) {
+        val useOldSid = site.isFeatureEnabled("ffUseOldSid", globals.config.featureFlags)
+        if (!useOldSid) {
           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")

programming_language: Scala
filename: SubscriberController.scala
change_type: ModificationType.MODIFY
diff: @@ -162,10 +162,8 @@ class SubscriberController @Inject()(cc: ControllerComponents, tyCtx: EdContext)
     // 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) {
+      val useOldSid = site.isFeatureEnabled("ffUseOldSid", globals.config.featureFlags)
+      if (!useOldSid) {
         UNTESTED
         throwForbidden("TyEWEAKSIDWS",
               s"Please log out and log in, to get a complete session id — \n" +

programming_language: Scala
filename: package.scala
change_type: ModificationType.MODIFY
diff: @@ -788,8 +788,7 @@ class EdSecurity(globals: Globals) {
     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 useOldSid = site.isFeatureEnabled("ffUseOldSid", globals.config.featureFlags)
 
     val ppt = dao.getParticipant(userId)
     throwForbiddenIf(ppt.exists(_.isGroup), "TyELGIGRP", "Cannot login as a group")  // [imp-groups]
@@ -804,7 +803,7 @@ class EdSecurity(globals: Globals) {
     // New better sid  [btr_sid]
     // ----------------------------------------
 
-    if (tryFancySid || useFancySid) {
+    if (!useOldSid) {
       val (newSidCookies, session) = genAndSaveFancySid(req, patId = userId,
             expireIdleAfterSecs = expireIdleAfterSecs, dao.now(),
             dao.asInstanceOf[SessionSiteDaoMixin])
@@ -931,10 +930,9 @@ class EdSecurity(globals: Globals) {
 
     val hasFancySid = anyFancySidPart12Maybe3.isDefined || anyFancySidPart4.isDefined ||
           anyFancySidPart5.isDefined
-    val useOnlyFancySid = site.isFeatureEnabled("ffUseNewSid", globals.config.featureFlags)
-    val tryFancySid = site.isFeatureEnabled("ffTryNewSid", globals.config.featureFlags)
+    val useOldSid = site.isFeatureEnabled("ffUseOldSid", globals.config.featureFlags)
 
-    if (useOnlyFancySid || (tryFancySid && hasFancySid)) {
+    if (!useOldSid) {
       var result = checkFancySessionId(anyPart12Maybe3 = anyFancySidPart12Maybe3,
             anyPart4 = anyFancySidPart4, anyPart5 = anyFancySidPart5,
             dao, now, expireIdleAfterMillis = expireIdleAfterMillis)

programming_language: Scala
filename: Server.ts
change_type: ModificationType.MODIFY
diff: @@ -1159,7 +1159,7 @@ export function loginWithOneTimeSecret(oneTimeLoginSecret: string,
 export function getCurSid12Maybe3(): St | N {  // [ts_authn_modl]
   const store: Store = debiki2.ReactStore.allData();
   const cookieName =
-          debiki2.store_isFeatFlagOn(store, 'ffUseNewSid') ? 'TyCoSid123' : 'dwCoSid';
+          !debiki2.store_isFeatFlagOn(store, 'ffUseOldSid') ? 'TyCoSid123' : 'dwCoSid';
   let sid = getSetCookie(cookieName);
   if (!sid) {
     // Cannot use store.me.mySidPart1 — we might not yet have loaded

programming_language: TypeScript
filename: start-page.ts
change_type: ModificationType.MODIFY
diff: @@ -71,7 +71,8 @@ function handleLoginInOtherBrowserTab() {
 
   // New style session id:  [btr_sid]
   // ------------------------------------------
-  if (debiki2.store_isFeatFlagOn(store, 'ffUseNewSid')) {
+  const useNewSid = !debiki2.store_isFeatFlagOn(store, 'ffUseOldSid');
+  if (useNewSid) {
     const sidParts123 = debiki2.Server.getCurSid12Maybe3();
     const stillTheSameSid = sidParts123
           ? me.mySidPart1 && sidParts123.startsWith(me.mySidPart1)

programming_language: JavaScript