From 3daf88160140e7453e607520b3faefda0157428d Mon Sep 17 00:00:00 2001 From: TheSaminator Date: Sat, 19 Feb 2022 10:27:11 -0500 Subject: [PATCH] Add CSRF to logout link, standardize user trophies, and anonymize anonymous profile pics --- .../kotlin/starshipfights/auth/providers.kt | 14 ++- .../kotlin/starshipfights/auth/utils.kt | 2 + .../starshipfights/data/admiralty/admirals.kt | 4 +- .../starshipfights/data/auth/user_sessions.kt | 24 ++++- .../starshipfights/data/auth/user_trophies.kt | 88 +++++++++++++++++++ .../starshipfights/data/data_documents.kt | 14 ++- .../kotlin/starshipfights/info/html_utils.kt | 4 + .../kotlin/starshipfights/info/view_nav.kt | 40 ++++++--- .../kotlin/starshipfights/info/views_user.kt | 71 +++++++++------ src/jvmMain/resources/static/init.js | 9 ++ 10 files changed, 223 insertions(+), 47 deletions(-) create mode 100644 src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt diff --git a/src/jvmMain/kotlin/starshipfights/auth/providers.kt b/src/jvmMain/kotlin/starshipfights/auth/providers.kt index 20bdce2..56ded5a 100644 --- a/src/jvmMain/kotlin/starshipfights/auth/providers.kt +++ b/src/jvmMain/kotlin/starshipfights/auth/providers.kt @@ -245,7 +245,7 @@ interface AuthProvider { if (shipType.weightClass.buyPrice > admiral.money) redirect("/admiral/${admiralId}/manage") - val ownedShips = ShipInDrydock.select(ShipInDrydock::owningAdmiral eq admiralId).toList() + val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() if (shipType.weightClass.isUnique) { val hasSameWeightClass = ownedShips.any { it.shipType.weightClass == shipType.weightClass } @@ -293,6 +293,8 @@ interface AuthProvider { } post("/logout") { + call.receiveValidatedParameters() + call.getUserSession()?.let { sess -> launch { val newTime = Instant.now().minusMillis(100) @@ -305,6 +307,8 @@ interface AuthProvider { } post("/logout/{id}") { + call.receiveValidatedParameters() + val id = Id(call.parameters.getOrFail("id")) call.getUserSession()?.let { sess -> launch { @@ -317,6 +321,8 @@ interface AuthProvider { } post("/logout-all") { + call.receiveValidatedParameters() + call.getUserSession()?.let { sess -> launch { val newTime = Instant.now().minusMillis(100) @@ -328,6 +334,8 @@ interface AuthProvider { } post("/clear-expired/{id}") { + call.receiveValidatedParameters() + val id = Id(call.parameters.getOrFail("id")) call.getUserSession()?.let { sess -> launch { @@ -340,6 +348,8 @@ interface AuthProvider { } post("/clear-all-expired") { + call.receiveValidatedParameters() + call.getUserSession()?.let { sess -> launch { val now = Instant.now() @@ -479,7 +489,7 @@ object TestAuthProvider : AuthProvider { } } -class ProductionAuthProvider(val discordLogin: DiscordLogin) : AuthProvider { +class ProductionAuthProvider(private val discordLogin: DiscordLogin) : AuthProvider { private val httpClient = HttpClient(Apache) override fun installAuth(conf: Authentication.Configuration) { diff --git a/src/jvmMain/kotlin/starshipfights/auth/utils.kt b/src/jvmMain/kotlin/starshipfights/auth/utils.kt index 781acf0..2df4248 100644 --- a/src/jvmMain/kotlin/starshipfights/auth/utils.kt +++ b/src/jvmMain/kotlin/starshipfights/auth/utils.kt @@ -37,6 +37,8 @@ suspend fun ApplicationCall.getUserSession() = request.userAgent()?.let { sessio suspend fun ApplicationCall.getUser() = getUserSession()?.user?.let { User.get(it) }?.updated() +suspend fun ApplicationCall.getUserAndSession() = getUserSession()?.let { it to User.get(it.user)?.updated() } ?: (null to null) + object UserSessionIdSerializer : SessionSerializer> { override fun serialize(session: Id): String { return session.id diff --git a/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt b/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt index 8abea3c..1828ea9 100644 --- a/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt +++ b/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt @@ -81,7 +81,7 @@ data class ShipInDrydock( }) } -suspend fun getAllInGameAdmirals(user: User) = Admiral.select(Admiral::owningUser eq user.id).map { admiral -> +suspend fun getAllInGameAdmirals(user: User) = Admiral.filter(Admiral::owningUser eq user.id).map { admiral -> InGameAdmiral( admiral.id.reinterpret(), InGameUser(user.id.reinterpret(), user.profileName), @@ -106,7 +106,7 @@ suspend fun getInGameAdmiral(admiralId: Id) = Admiral.get(admiral } suspend fun getAdmiralsShips(admiralId: Id) = ShipInDrydock - .select(ShipInDrydock::owningAdmiral eq admiralId) + .filter(ShipInDrydock::owningAdmiral eq admiralId) .toList() .filter { it.isReady } .associate { it.shipData.id to it.shipData } diff --git a/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt b/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt index 23d60a3..2b79c89 100644 --- a/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt +++ b/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt @@ -31,13 +31,15 @@ data class User( val logIpAddresses: Boolean, val status: UserStatus = UserStatus.AVAILABLE, + + val amountDonatedInUsCents: Int = 0, ) : DataDocument { val discordAvatarUrl: String get() = discordAvatar?.takeIf { showDiscordName }?.let { "https://cdn.discordapp.com/avatars/$discordId/$it." + (if (it.startsWith("a_")) "gif" else "png") + "?size=256" } ?: anonymousAvatarUrl - private val anonymousAvatarUrl: String + val anonymousAvatarUrl: String get() = "https://cdn.discordapp.com/embed/avatars/${(discordDiscriminator.lastOrNull()?.digitToInt() ?: 0) % 5}.png" companion object Table : DocumentTable by DocumentTable.create({ @@ -63,3 +65,23 @@ data class UserSession( index(UserSession::user) }) } + +/* +@Serializable +data class PrivateMessage( + @SerialName("_id") + override val id: Id = Id(), + val sender: Id, + val receiver: Id, + val subject: String, + val message: String, + val sentAt: @Contextual Instant, + val isRead: Boolean +) : DataDocument { + companion object Table : DocumentTable by DocumentTable.create({ + index(PrivateMessage::sender) + index(PrivateMessage::receiver) + index(PrivateMessage::sentAt) + }) +} +*/ diff --git a/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt b/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt new file mode 100644 index 0000000..d39c90f --- /dev/null +++ b/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt @@ -0,0 +1,88 @@ +package starshipfights.data.auth + +import kotlinx.html.* +import kotlinx.serialization.Serializable +import starshipfights.CurrentConfiguration + +@Serializable +sealed class UserTrophy : Comparable { + protected abstract fun ASIDE.render() + fun renderInto(sidebar: ASIDE) = sidebar.render() + + // Higher rank = lower on page + protected abstract val rank: Int + override fun compareTo(other: UserTrophy): Int { + return rank.compareTo(other.rank) + } +} + +fun ASIDE.renderTrophy(trophy: UserTrophy) = trophy.renderInto(this) + +@Serializable +object SiteOwnerTrophy : UserTrophy() { + override fun ASIDE.render() { + p { + style = "text-align:center;border:2px solid #a82;padding:3px;background-color:#fc3;color:#541;font-variant:small-caps;font-family:'Orbitron',sans-serif" + +"Site Owner" + } + } + + override val rank: Int + get() = 0 +} + +@Serializable +object SiteDeveloperTrophy : UserTrophy() { + override fun ASIDE.render() { + p { + style = "text-align:center;border:2px solid #62a;padding:3px;background-color:#93f;color:#315;font-variant:small-caps;font-family:'Orbitron',sans-serif" + title = "This person helps with coding the site" + +"Site Developer" + } + } + + override val rank: Int + get() = 1 +} + +data class SiteJanitorTrophy(val isSenior: Boolean) : UserTrophy() { + override fun ASIDE.render() { + p { + style = "text-align:center;border:2px solid #840;padding:3px;background-color:#c60;color:#420;font-variant:small-caps;font-family:'Orbitron',sans-serif" + title = "This person helps with cleaning the poo out of the site" + +if (isSenior) "Senior Janitor" else "Janitor" + } + } + + override val rank: Int + get() = 2 +} + +@Serializable +data class SiteSupporterTrophy(val amountInUsCents: Int) : UserTrophy() { + override fun ASIDE.render() { + p { + style = "text-align:center;border:2px solid #696;padding:3px;background-color:#afa;color:#232;font-variant:small-caps;font-family:'Orbitron',sans-serif" + title = "\"I spent money on an online game and all I got was this lousy trophy!\"" + +"Site Supporter:" + br + +when { + amountInUsCents < 100 -> "Rear Admiral" + amountInUsCents < 500 -> "Vice Admiral" + amountInUsCents < 1000 -> "Admiral" + amountInUsCents < 2000 -> "High Admiral" + else -> "Lord Admiral" + } + } + } + + override val rank: Int + get() = 3 +} + +fun User.getTrophies(): List = + (if (discordId == CurrentConfiguration.discordClient?.ownerId) + listOf(SiteOwnerTrophy) + else emptyList()) + (if (amountDonatedInUsCents > 0) + listOf(SiteSupporterTrophy(amountDonatedInUsCents)) + else emptyList()) diff --git a/src/jvmMain/kotlin/starshipfights/data/data_documents.kt b/src/jvmMain/kotlin/starshipfights/data/data_documents.kt index dd5cbc6..2df868a 100644 --- a/src/jvmMain/kotlin/starshipfights/data/data_documents.kt +++ b/src/jvmMain/kotlin/starshipfights/data/data_documents.kt @@ -45,8 +45,10 @@ interface DocumentTable> { suspend fun del(id: Id) suspend fun all(): Flow - suspend fun select(where: Bson): Flow + suspend fun filter(where: Bson): Flow suspend fun sorted(order: Bson): Flow + suspend fun select(where: Bson, order: Bson): Flow + suspend fun number(where: Bson): Long suspend fun locate(where: Bson): T? suspend fun update(where: Bson, set: Bson) suspend fun remove(where: Bson) @@ -106,7 +108,7 @@ private class DocumentTableImpl>(val kclass: KClass, priv return collection().find().toFlow() } - override suspend fun select(where: Bson): Flow { + override suspend fun filter(where: Bson): Flow { return collection().find(where).toFlow() } @@ -114,6 +116,14 @@ private class DocumentTableImpl>(val kclass: KClass, priv return collection().find().sort(order).toFlow() } + override suspend fun select(where: Bson, order: Bson): Flow { + return collection().find(where).sort(order).toFlow() + } + + override suspend fun number(where: Bson): Long { + return collection().countDocuments(where) + } + override suspend fun locate(where: Bson): T? { return collection().findOne(where) } diff --git a/src/jvmMain/kotlin/starshipfights/info/html_utils.kt b/src/jvmMain/kotlin/starshipfights/info/html_utils.kt index c8f0dbb..881ff9e 100644 --- a/src/jvmMain/kotlin/starshipfights/info/html_utils.kt +++ b/src/jvmMain/kotlin/starshipfights/info/html_utils.kt @@ -16,6 +16,10 @@ var A.method: String? attributes.remove("data-method") } +fun A.csrfToken(cookie: Id) { + attributes["data-csrf-token"] = CsrfProtector.newNonce(cookie, this.href) +} + fun FORM.csrfToken(cookie: Id) = hiddenInput { name = CsrfProtector.csrfInputName value = CsrfProtector.newNonce(cookie, this@csrfToken.action) diff --git a/src/jvmMain/kotlin/starshipfights/info/view_nav.kt b/src/jvmMain/kotlin/starshipfights/info/view_nav.kt index 21c3425..1bee1c7 100644 --- a/src/jvmMain/kotlin/starshipfights/info/view_nav.kt +++ b/src/jvmMain/kotlin/starshipfights/info/view_nav.kt @@ -5,7 +5,9 @@ import kotlinx.html.DIV import kotlinx.html.a import kotlinx.html.span import kotlinx.html.style -import starshipfights.auth.getUser +import starshipfights.auth.getUserAndSession +import starshipfights.data.Id +import starshipfights.data.auth.UserSession sealed class NavItem { protected abstract fun DIV.display() @@ -21,11 +23,13 @@ data class NavHead(val label: String) : NavItem() { } } -data class NavLink(val to: String, val text: String, val isPost: Boolean = false) : NavItem() { +data class NavLink(val to: String, val text: String, val isPost: Boolean = false, val csrfUserCookie: Id? = null) : NavItem() { override fun DIV.display() { a(href = to) { if (isPost) method = "post" + csrfUserCookie?.let { csrfToken(it) } + +text } } @@ -37,16 +41,28 @@ suspend fun ApplicationCall.standardNavBar(): List = listOf( NavLink("/about", "About Starship Fights"), NavLink("/users", "New Users"), NavHead("Your Account"), -) + when (val user = getUser()) { - null -> listOf( - NavLink("/login", "Login with Discord"), - ) - else -> listOf( - NavLink("/me", user.profileName), - NavLink("/me/manage", "User Preferences"), - NavLink("/lobby", "Enter Game Lobby"), - NavLink("/logout", "Log Out", isPost = true), - ) +) + getUserAndSession().let { (session, user) -> + if (session == null || user == null) + listOf( + NavLink("/login", "Login with Discord"), + ) + else + listOf( + NavLink("/me", user.profileName), + NavLink("/me/manage", "User Preferences"), + /*NavLink( + "/me/inbox", "Inbox (${ + PrivateMessage.number( + and( + PrivateMessage::receiver eq user.id, + PrivateMessage::isRead eq false + ) + ) + })" + ),*/ + NavLink("/lobby", "Enter Game Lobby"), + NavLink("/logout", "Log Out", isPost = true, csrfUserCookie = session.id), + ) } + listOf( NavHead("External Information"), NavLink("https://mechyrdia.netlify.app/", "Mechyrdia Infobase"), diff --git a/src/jvmMain/kotlin/starshipfights/info/views_user.kt b/src/jvmMain/kotlin/starshipfights/info/views_user.kt index e29c50f..b337c4a 100644 --- a/src/jvmMain/kotlin/starshipfights/info/views_user.kt +++ b/src/jvmMain/kotlin/starshipfights/info/views_user.kt @@ -4,49 +4,58 @@ import io.ktor.application.* import io.ktor.features.* import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.toList import kotlinx.html.* import org.litote.kmongo.and import org.litote.kmongo.eq import org.litote.kmongo.gt import org.litote.kmongo.or -import starshipfights.CurrentConfiguration import starshipfights.ForbiddenException import starshipfights.auth.* import starshipfights.data.Id import starshipfights.data.admiralty.* -import starshipfights.data.auth.User -import starshipfights.data.auth.UserSession -import starshipfights.data.auth.UserStatus +import starshipfights.data.auth.* import starshipfights.game.* import starshipfights.redirect import java.time.Instant suspend fun ApplicationCall.userPage(): HTML.() -> Unit { - val username = Id(parameters["id"]!!) - val user = User.get(username)!! + val userId = Id(parameters["id"]!!) + val user = User.get(userId)!! + val currentUser = getUserSession() - val isCurrentUser = user.id == getUserSession()?.user - val hasOpenSessions = UserSession.select( - and(UserSession::user eq username, UserSession::expiration gt Instant.now()) - ).firstOrNull() != null + val isCurrentUser = user.id == currentUser?.id + val hasOpenSessions = UserSession.locate( + and(UserSession::user eq userId, UserSession::expiration gt Instant.now()) + ) != null - val admirals = Admiral.select(Admiral::owningUser eq user.id).toList() + val admirals = Admiral.filter(Admiral::owningUser eq user.id).toList() return page( user.profileName, standardNavBar(), CustomSidebar { - img(src = user.discordAvatarUrl) { - style = "border-radius:50%" - } - if (user.showDiscordName) + if (user.showDiscordName) { + img(src = user.discordAvatarUrl) { + style = "border-radius:50%" + } p { style = "text-align:center" +user.discordName +"#" +user.discordDiscriminator } - if (user.showUserStatus) + } else { + img(src = user.anonymousAvatarUrl) { + style = "border-radius:50%" + } + p { + style = "text-align:center" + +"Anonymous User#0000" + } + } + user.getTrophies().forEach { trophy -> + renderTrophy(trophy) + } + if (user.showUserStatus) { p { style = "text-align:center" +when (user.status) { @@ -56,12 +65,6 @@ suspend fun ApplicationCall.userPage(): HTML.() -> Unit { UserStatus.AVAILABLE -> if (hasOpenSessions) "Online" else "Offline" } } - if (user.discordId == CurrentConfiguration.discordClient?.ownerId) - p { - style = "text-align:center;border:2px solid #a82;padding:3px;background-color:#fc3;color:#a82;font-variant:small-caps;font-family:'Orbitron',sans-serif" - +"Site Owner" - } - if (user.showUserStatus) p { style = "text-align:center" +"Registered at " @@ -76,6 +79,7 @@ suspend fun ApplicationCall.userPage(): HTML.() -> Unit { +user.lastActivity.toEpochMilli().toString() } } + } if (isCurrentUser) { hr { style = "border-color:#036" } div(classes = "list") { @@ -86,7 +90,14 @@ suspend fun ApplicationCall.userPage(): HTML.() -> Unit { a(href = "/me/manage") { +"Edit Profile" } } } - } + } /*else if (currentUser != null) { + hr { style = "border-color:#036" } + div(classes = "list") { + div(classes = "item") { + a(href = "/user/${userId}/send") { +"Send Message" } + } + } + }*/ } ) { section { @@ -119,7 +130,7 @@ suspend fun ApplicationCall.userPage(): HTML.() -> Unit { suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { val currentSession = getUserSession() ?: redirect("/login") val currentUser = User.get(currentSession.user) ?: redirect("/login") - val allUserSessions = UserSession.select(and(UserSession::user eq currentUser.id)).toList() + val allUserSessions = UserSession.filter(and(UserSession::user eq currentUser.id)).toList() return page( "User Preferences", standardNavBar(), PageNavSidebar( @@ -243,6 +254,7 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { } a(href = "/logout/${session.id}") { method = "post" + csrfToken(session.id) +"Logout" } } @@ -253,6 +265,7 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { colSpan = if (currentUser.logIpAddresses) "3" else "2" a(href = "/logout-all") { method = "post" + csrfToken(currentSession.id) +"Logout All" } } @@ -276,6 +289,7 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { br a(href = "/clear-expired/${session.id}") { method = "post" + csrfToken(currentSession.id) +"Clear" } } @@ -287,6 +301,7 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { colSpan = if (currentUser.logIpAddresses) "3" else "2" a(href = "/clear-all-expired") { method = "post" + csrfToken(currentSession.id) +"Clear All Expired Sessions" } } @@ -390,8 +405,8 @@ suspend fun ApplicationCall.admiralPage(): HTML.() -> Unit { val admiralId = parameters["id"]?.let { Id(it) }!! val (admiral, ships, records) = coroutineScope { val admiral = async { Admiral.get(admiralId)!! } - val ships = async { ShipInDrydock.select(ShipInDrydock::owningAdmiral eq admiralId).toList() } - val records = async { BattleRecord.select(or(BattleRecord::hostAdmiral eq admiralId, BattleRecord::guestAdmiral eq admiralId)).toList() } + val ships = async { ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() } + val records = async { BattleRecord.filter(or(BattleRecord::hostAdmiral eq admiralId, BattleRecord::guestAdmiral eq admiralId)).toList() } Triple(admiral.await(), ships.await(), records.await()) } @@ -545,7 +560,7 @@ suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { if (admiral.owningUser != currentUser) throw ForbiddenException() - val ownedShips = ShipInDrydock.select(ShipInDrydock::owningAdmiral eq admiralId).toList() + val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() val buyableShips = ShipType.values().filter { type -> type.faction == admiral.faction && type.weightClass.rank <= admiral.rank.maxShipWeightClass.rank && type.weightClass.buyPrice <= admiral.money && (if (type.weightClass.isUnique) ownedShips.none { it.shipType.weightClass == type.weightClass } else true) diff --git a/src/jvmMain/resources/static/init.js b/src/jvmMain/resources/static/init.js index 9609d14..18c7e47 100644 --- a/src/jvmMain/resources/static/init.js +++ b/src/jvmMain/resources/static/init.js @@ -139,6 +139,15 @@ window.addEventListener("load", function () { form.action = anchor.href; form.method = method; + const csrfToken = anchor.getAttribute("data-csrf-token"); + if (csrfToken != null) { + let csrfInput = document.createElement("input"); + csrfInput.name = "csrf-token"; + csrfInput.type = "hidden"; + csrfInput.value = csrfToken; + form.append(csrfInput); + } + document.body.append(form); form.submit(); }; -- 2.25.1