Rework NationStates login token storage, as well as make sessions perishable
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 5 May 2024 14:30:22 +0000 (10:30 -0400)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 5 May 2024 14:30:22 +0000 (10:30 -0400)
src/jvmMain/kotlin/info/mechyrdia/auth/SessionStorage.kt
src/jvmMain/kotlin/info/mechyrdia/auth/ViewsLogin.kt
src/jvmMain/kotlin/info/mechyrdia/data/Comments.kt
src/jvmMain/kotlin/info/mechyrdia/data/Data.kt
src/jvmMain/kotlin/info/mechyrdia/data/ViewsComment.kt
src/jvmMain/kotlin/info/mechyrdia/data/Visits.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceBodies.kt
src/jvmMain/kotlin/info/mechyrdia/route/ResourceTypes.kt

index c0ab266353c4104650358dc1b8d7504a91b19080..0fb6763e276785f652f5484e6621f1fb1984c7f1 100644 (file)
@@ -5,10 +5,12 @@ import info.mechyrdia.data.*
 import io.ktor.server.sessions.*
 import kotlinx.serialization.SerialName
 import kotlinx.serialization.Serializable
+import java.time.Instant
 
 object SessionStorageMongoDB : SessionStorage {
        override suspend fun invalidate(id: String) {
-               SessionStorageDoc.Table.del(Id(id))
+               if (!SessionStorageDoc.Table.del(Id(id)))
+                       throw NoSuchElementException("Session $id not found")
        }
        
        override suspend fun read(id: String): String {
@@ -27,10 +29,14 @@ data class SessionStorageDoc(
        @SerialName(MONGODB_ID_KEY)
        override val id: Id<SessionStorageDoc>,
        val session: UserSession,
+       
+       val expiresAt: @Serializable(with = InstantSerializer::class) Instant = Instant.now().plusSeconds(1_814_400),
 ) : DataDocument<SessionStorageDoc> {
        companion object : TableHolder<SessionStorageDoc> {
                override val Table = DocumentTable<SessionStorageDoc>()
                
-               override suspend fun initialize() = Unit
+               override suspend fun initialize() {
+                       Table.expire(SessionStorageDoc::expiresAt)
+               }
        }
 }
index 03ce61fc6c7e176f6191a3dcf6e7a3020f6ca8b8..e81e3cf5da301f8fa70bdf621aceb6ad932a6017 100644 (file)
@@ -2,8 +2,7 @@ package info.mechyrdia.auth
 
 import com.github.agadar.nationstates.shard.NationShard
 import info.mechyrdia.Configuration
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
+import info.mechyrdia.data.*
 import info.mechyrdia.lore.page
 import info.mechyrdia.lore.redirectHref
 import info.mechyrdia.lore.standardNavBar
@@ -15,30 +14,58 @@ import io.ktor.server.plugins.*
 import io.ktor.server.sessions.*
 import io.ktor.util.*
 import kotlinx.html.*
-import java.util.concurrent.ConcurrentHashMap
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.time.Instant
 import kotlin.collections.set
 
 val PageDoNotCacheAttributeKey = AttributeKey<Boolean>("Mechyrdia.PageDoNotCache")
 
-private val nsTokenMap = ConcurrentHashMap<String, String>()
+@Serializable
+data class NsStoredToken(
+       @SerialName(MONGODB_ID_KEY)
+       override val id: Id<NsStoredToken> = Id(),
+       val verifyToken: String = token(),
+       
+       val expiresAt: @Serializable(with = InstantSerializer::class) Instant = Instant.now().plusSeconds(1800L),
+) : DataDocument<NsStoredToken> {
+       companion object : TableHolder<NsStoredToken> {
+               override val Table = DocumentTable<NsStoredToken>()
+               
+               override suspend fun initialize() {
+                       Table.expire(NsStoredToken::expiresAt)
+               }
+               
+               suspend fun createToken(): NsLoginToken {
+                       return NsStoredToken().also { Table.put(it) }.let { (id, token) ->
+                               NsLoginToken(id.id, token)
+                       }
+               }
+               
+               suspend fun verifyToken(id: String): String? {
+                       return Table.get(Id(id))?.verifyToken?.also {
+                               Table.del(Id(id))
+                       }
+               }
+       }
+}
+
+data class NsLoginToken(val id: String, val token: String)
 
 suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
        attributes.put(PageDoNotCacheAttributeKey, true)
        
+       val (tokenId, nsToken) = NsStoredToken.createToken()
+       
        return page("Log In With NationStates", standardNavBar()) {
-               val tokenKey = token()
-               val nsToken = token()
-               
-               nsTokenMap[tokenKey] = nsToken
-               
                section {
                        h1 { +"Log In With NationStates" }
                        form(method = FormMethod.post, action = href(Root.Auth.LoginPost())) {
                                installCsrfToken()
                                
                                hiddenInput {
-                                       name = "token"
-                                       value = tokenKey
+                                       name = "tokenId"
+                                       value = tokenId
                                }
                                
                                label {
@@ -70,10 +97,10 @@ suspend fun ApplicationCall.loginPage(): HTML.() -> Unit {
        }
 }
 
-suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, token: String): Nothing {
+suspend fun ApplicationCall.loginRoute(nation: String, checksum: String, tokenId: String): Nothing {
        val nationId = nation.toNationId()
-       val nsToken = nsTokenMap.remove(token)
-               ?: throw MissingRequestParameterException("token")
+       val nsToken = NsStoredToken.verifyToken(tokenId)
+               ?: throw MissingRequestParameterException("tokenId")
        
        val nationData = if (nationId == Configuration.Current.ownerNation && checksum == Configuration.Current.emergencyPassword)
                NationData.get(Id(nationId))
index f2d4320fa023cb2a6ec610baaf4a3fc992108468..0829658d74e5e4a5527cd9288d20d7abdfe3b448 100644 (file)
@@ -10,7 +10,7 @@ import java.time.Instant
 @Serializable
 data class Comment(
        @SerialName(MONGODB_ID_KEY)
-       override val id: Id<Comment>,
+       override val id: Id<Comment> = Id(),
        
        val submittedBy: Id<NationData>,
        val submittedIn: String,
@@ -42,7 +42,7 @@ data class Comment(
 @Serializable
 data class CommentReplyLink(
        @SerialName(MONGODB_ID_KEY)
-       override val id: Id<CommentReplyLink> = Id(),
+       override val id: Id<CommentReplyLink>,
        
        val originalPost: Id<Comment>,
        val replyingPost: Id<Comment>,
index bb6b356d4519516842534722897257fbbc7bb892..bfc810ea1e2a65bc84eab920f5a24a773ff5a013 100644 (file)
@@ -10,6 +10,7 @@ import com.mongodb.kotlin.client.coroutine.expireAfter
 import com.mongodb.reactivestreams.client.MongoClients
 import com.mongodb.reactivestreams.client.gridfs.GridFSBucket
 import com.mongodb.reactivestreams.client.gridfs.GridFSBuckets
+import info.mechyrdia.auth.NsStoredToken
 import info.mechyrdia.auth.SessionStorageDoc
 import info.mechyrdia.auth.WebDavToken
 import info.mechyrdia.robot.RobotGlobals
@@ -186,8 +187,8 @@ class DocumentTable<T : DataDocument<T>>(private val kClass: KClass<T>) {
                return collection().find(Filters.eq(MONGODB_ID_KEY, id)).singleOrNull()
        }
        
-       suspend fun del(id: Id<T>) {
-               collection().deleteOne(Filters.eq(MONGODB_ID_KEY, id))
+       suspend fun del(id: Id<T>): Boolean {
+               return collection().deleteOne(Filters.eq(MONGODB_ID_KEY, id)).deletedCount > 0L
        }
        
        suspend fun all(): Flow<T> {
@@ -272,6 +273,7 @@ interface TableHolder<T : DataDocument<T>> {
                val entries = listOf(
                        SessionStorageDoc,
                        NationData,
+                       NsStoredToken,
                        WebDavToken,
                        Comment,
                        CommentReplyLink,
index e19bb69308b2d8693b1cb9001b439d146c2913e9..788075c603ed70f777fec8048a7719e0d2bde40f 100644 (file)
@@ -71,7 +71,6 @@ suspend fun ApplicationCall.newCommentRoute(pagePathParts: List<String>, content
        
        val now = Instant.now()
        val comment = Comment(
-               id = Id(),
                submittedBy = loggedInAs.id,
                submittedIn = pagePathParts.joinToString("/"),
                submittedAt = now,
index 614cd2ebd47b0af4bc7d103e255d67c5750e6dec..e678ed807c029daf2bd7e1f5bd054c0c4c130de9 100644 (file)
@@ -28,7 +28,7 @@ data class PageVisitTotals(
 @Serializable
 data class PageVisitData(
        @SerialName(MONGODB_ID_KEY)
-       override val id: Id<PageVisitData> = Id(),
+       override val id: Id<PageVisitData>,
        
        val path: String,
        val visitor: String,
index 3bdd9d91b9bd6bac6170ce658a34865163da3483..dea60c48205250be7e3628db39c37e6120a21020 100644 (file)
@@ -7,7 +7,7 @@ import kotlinx.html.textArea
 import kotlinx.serialization.Serializable
 
 @Serializable
-class LoginPayload(override val csrfToken: String? = null, val nation: String, val checksum: String, val token: String) : CsrfProtectedResourcePayload
+class LoginPayload(override val csrfToken: String? = null, val nation: String, val checksum: String, val tokenId: String) : CsrfProtectedResourcePayload
 
 @Serializable
 class LogoutPayload(override val csrfToken: String? = null) : CsrfProtectedResourcePayload
index 1b30ea216c39c601d269c0c3da1ae2de32e1d38a..b1e33b57064be26cec4300314282fff3e1c2e8e3 100644 (file)
@@ -135,7 +135,7 @@ class Root(val error: String? = null) : ResourceHandler, ResourceFilter {
                                with(auth) { filterCall() }
                                with(payload) { call.verifyCsrfToken() }
                                
-                               call.loginRoute(payload.nation, payload.checksum, payload.token)
+                               call.loginRoute(payload.nation, payload.checksum, payload.tokenId)
                        }
                }