import java.io.IOException
import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random
+import kotlin.time.Duration.Companion.hours
fun main() {
System.setProperty("logback.statusListenerClass", "ch.qos.logback.core.status.NopStatusListener")
serializer = KotlinxSessionSerializer(UserSession.serializer(), JsonStorageCodec)
+ cookie.maxAge = 336.hours
cookie.secure = true
cookie.httpOnly = true
cookie.extensions["SameSite"] = "Lax"
package info.mechyrdia.auth
+import info.mechyrdia.JsonStorageCodec
import info.mechyrdia.data.*
import io.ktor.server.sessions.*
import kotlinx.serialization.SerialName
}
override suspend fun read(id: String): String {
- return SessionStorageDoc.Table.get(Id(id))?.session ?: throw NoSuchElementException("Session $id not found")
+ val value = SessionStorageDoc.Table.get(Id(id))?.session ?: throw NoSuchElementException("Session $id not found")
+ return JsonStorageCodec.encodeToString(UserSession.serializer(), value)
}
override suspend fun write(id: String, value: String) {
- SessionStorageDoc.Table.put(SessionStorageDoc(Id(id), value))
+ val session = JsonStorageCodec.decodeFromString(UserSession.serializer(), value)
+ SessionStorageDoc.Table.put(SessionStorageDoc(Id(id), session))
}
}
data class SessionStorageDoc(
@SerialName(MONGODB_ID_KEY)
override val id: Id<SessionStorageDoc>,
- val session: String
+ val session: UserSession,
) : DataDocument<SessionStorageDoc> {
companion object : TableHolder<SessionStorageDoc> {
override val Table = DocumentTable<SessionStorageDoc>()
--- /dev/null
+package info.mechyrdia.auth
+
+import info.mechyrdia.data.Id
+import info.mechyrdia.data.InstantSerializer
+import info.mechyrdia.data.NationData
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.sessions.*
+import kotlinx.serialization.Serializable
+import java.time.Instant
+
+@Serializable
+data class CsrfTokenEntry(
+ val targetRoute: String,
+ val expiresAt: @Serializable(with = InstantSerializer::class) Instant,
+)
+
+@Serializable
+data class UserSession(
+ val nationId: Id<NationData>? = null,
+ val csrfTokens: Map<String, CsrfTokenEntry> = emptyMap(),
+)
+
+var ApplicationCall.currentUserSession: UserSession
+ get() = sessions.get<UserSession>() ?: UserSession().also { sessions.set(it) }
+ set(value) = sessions.set(value)
+
+suspend fun ApplicationCall.updateUserSession(session: UserSession) {
+ sessionId<UserSession>()?.let {
+ SessionStorageDoc.Table.put(SessionStorageDoc(Id(it), session))
+ }
+}
+
+const val DEFAULT_CSRF_TOKEN_EXPIRY_SECONDS = 7200
+
+fun ApplicationCall.createCsrfToken(targetRoute: String = request.origin.uri, expireSeconds: Int = DEFAULT_CSRF_TOKEN_EXPIRY_SECONDS): String {
+ val token = token()
+ val entry = CsrfTokenEntry(
+ targetRoute = targetRoute,
+ expiresAt = Instant.now().plusSeconds(expireSeconds.toLong())
+ )
+
+ currentUserSession = currentUserSession.let { sess ->
+ sess.copy(csrfTokens = sess.csrfTokens + (token to entry))
+ }
+
+ return token
+}
+
+suspend fun ApplicationCall.retrieveCsrfToken(token: String): CsrfTokenEntry? {
+ val session = currentUserSession
+ val entry = session.csrfTokens[token] ?: return null
+
+ updateUserSession(session.let { sess ->
+ sess.copy(csrfTokens = sess.csrfTokens - token)
+ })
+
+ return entry
+}
+++ /dev/null
-package info.mechyrdia.auth
-
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class UserSession(
- val nationId: Id<NationData>,
-)
val ApplicationCall.nationCache: MutableMap<Id<NationData>, NationData>
get() = attributes.computeIfAbsent(CallNationCacheAttribute) {
- ConcurrentHashMap<Id<NationData>, NationData>().also { cache ->
- attributes.put(CallNationCacheAttribute, cache)
- }
+ ConcurrentHashMap<Id<NationData>, NationData>()
}
suspend fun MutableMap<Id<NationData>, NationData>.getNation(id: Id<NationData>): NationData {
import com.mongodb.client.model.Aggregates
import com.mongodb.client.model.Filters
import com.mongodb.client.model.Updates
+import info.mechyrdia.auth.UserSession
import info.mechyrdia.lore.dateTime
import io.ktor.server.application.*
-import io.ktor.server.plugins.*
import io.ktor.server.request.*
import io.ktor.server.sessions.*
-import io.ktor.util.*
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.html.FlowContent
import kotlinx.html.p
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.intellij.lang.annotations.Language
-import java.security.MessageDigest
import java.time.Instant
@Serializable
}
}
-private val messageDigestProvider = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
-
-fun ApplicationCall.anonymizedClientId(): String {
- val messageDigest = messageDigestProvider.get()
-
- messageDigest.reset()
- messageDigest.update(request.origin.remoteAddress.encodeToByteArray())
- request.userAgent()?.encodeToByteArray()?.let { messageDigest.update(it) }
-
- return hex(messageDigest.digest())
-}
-
suspend fun ApplicationCall.processGuestbook(): PageVisitTotals {
val path = request.path()
val totals = PageVisitData.totalVisits(path)
if (!RobotDetector.isRobot(request.userAgent()))
- PageVisitData.visit(path, anonymizedClientId())
+ sessionId<UserSession>()?.let { PageVisitData.visit(path, it) }
return totals
}
import info.mechyrdia.JsonFileCodec
import info.mechyrdia.OwnerNationId
+import info.mechyrdia.auth.createCsrfToken
import info.mechyrdia.data.FileStorage
import info.mechyrdia.data.StoragePath
import info.mechyrdia.data.currentNation
import info.mechyrdia.robot.RobotService
import info.mechyrdia.robot.RobotServiceStatus
import info.mechyrdia.route.Root
-import info.mechyrdia.route.createCsrfToken
import info.mechyrdia.route.href
import io.ktor.server.application.*
import kotlinx.html.*
package info.mechyrdia.robot
+import info.mechyrdia.auth.createCsrfToken
import info.mechyrdia.data.currentNation
import info.mechyrdia.lore.page
import info.mechyrdia.lore.redirectHref
import info.mechyrdia.lore.standardNavBar
import info.mechyrdia.route.Root
import info.mechyrdia.route.checkCsrfToken
-import info.mechyrdia.route.createCsrfToken
import info.mechyrdia.route.href
import io.ktor.server.application.*
import io.ktor.server.websocket.*
package info.mechyrdia.route
-import info.mechyrdia.auth.UserSession
-import info.mechyrdia.auth.token
-import info.mechyrdia.data.Id
-import info.mechyrdia.data.NationData
+import info.mechyrdia.auth.createCsrfToken
+import info.mechyrdia.auth.retrieveCsrfToken
import io.ktor.server.application.*
-import io.ktor.server.plugins.*
import io.ktor.server.request.*
-import io.ktor.server.sessions.*
import kotlinx.html.A
import kotlinx.html.FORM
import kotlinx.html.FlowContent
import kotlinx.html.hiddenInput
import java.time.Instant
-import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.set
-data class CsrfPayload(
- val route: String,
- val remoteAddress: String,
- val userAgent: String?,
- val userAccount: Id<NationData>?,
- val expires: Instant
-)
-
-fun ApplicationCall.csrfPayload(route: String, withExpiration: Instant = Instant.now().plusSeconds(7200)) =
- CsrfPayload(
- route = route,
- remoteAddress = request.origin.remoteAddress,
- userAgent = request.userAgent(),
- userAccount = sessions.get<UserSession>()?.nationId,
- expires = withExpiration
- )
-
-private val csrfMap = ConcurrentHashMap<String, CsrfPayload>()
-
data class CsrfFailedException(override val message: String, val payload: CsrfProtectedResourcePayload?) : RuntimeException(message)
interface CsrfProtectedResourcePayload {
val csrfToken: String?
- fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
+ suspend fun ApplicationCall.verifyCsrfToken(route: String = request.uri) {
val token = csrfToken ?: throw CsrfFailedException("The submitted CSRF token is not present", this@CsrfProtectedResourcePayload)
- val check = csrfMap.remove(token) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload)
- val payload = csrfPayload(route, check.expires)
- if (check != payload)
+ val entry = retrieveCsrfToken(token) ?: throw CsrfFailedException("The submitted CSRF token is not valid", this@CsrfProtectedResourcePayload)
+ if (entry.targetRoute != route)
throw CsrfFailedException("The submitted CSRF token does not match", this@CsrfProtectedResourcePayload)
- if (payload.expires < Instant.now())
+ if (entry.expiresAt < Instant.now())
throw CsrfFailedException("The submitted CSRF token has expired", this@CsrfProtectedResourcePayload)
}
fun FlowContent.displayRetryData() {}
}
-fun ApplicationCall.checkCsrfToken(csrfToken: String?, route: String = request.uri): Boolean {
+suspend fun ApplicationCall.checkCsrfToken(csrfToken: String?, route: String = request.uri): Boolean {
val token = csrfToken ?: return false
- val check = csrfMap.remove(token) ?: return false
- val payload = csrfPayload(route, check.expires)
- return check == payload && payload.expires >= Instant.now()
-}
-
-fun ApplicationCall.createCsrfToken(route: String = request.origin.uri): String {
- return token().also { csrfMap[it] = csrfPayload(route) }
+ val entry = retrieveCsrfToken(token) ?: return false
+ return entry.targetRoute == route && entry.expiresAt >= Instant.now()
}
context(ApplicationCall)
fun A.installCsrfToken(route: String = href) {
attributes["data-method"] = "post"
- attributes["data-csrf-token"] = token().also { csrfMap[it] = csrfPayload(route) }
+ attributes["data-csrf-token"] = createCsrfToken(route)
}
context(ApplicationCall)
fun FORM.installCsrfToken(route: String = action) {
hiddenInput {
name = "csrfToken"
- value = token().also { csrfMap[it] = csrfPayload(route) }
+ value = createCsrfToken(route)
}
}