package starshipfights.auth
-import com.mongodb.MongoException
import io.ktor.application.*
import io.ktor.auth.*
+import io.ktor.client.*
+import io.ktor.client.engine.apache.*
+import io.ktor.client.request.*
import io.ktor.features.*
import io.ktor.html.*
import io.ktor.http.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.html.*
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
import org.litote.kmongo.and
import org.litote.kmongo.eq
import org.litote.kmongo.ne
import org.litote.kmongo.setValue
-import starshipfights.CurrentConfiguration
+import starshipfights.*
import starshipfights.data.Id
import starshipfights.data.admiralty.Admiral
import starshipfights.data.admiralty.ShipInDrydock
import starshipfights.data.admiralty.generateFleet
-import starshipfights.data.auth.*
+import starshipfights.data.auth.User
+import starshipfights.data.auth.UserSession
+import starshipfights.data.createNonce
import starshipfights.game.AdmiralRank
import starshipfights.game.Faction
import starshipfights.info.*
-import starshipfights.redirect
interface AuthProvider {
fun installApplication(app: Application) = Unit
companion object Installer {
private val currentProvider: AuthProvider
- get() = if (CurrentConfiguration.isDevEnv)
- TestAuthProvider
- else
- TODO("Need to implement production AuthProvider")
+ get() = CurrentConfiguration.discordClient?.let { ProductionAuthProvider(it) } ?: TestAuthProvider
fun install(into: Application) {
currentProvider.installApplication(into)
authenticate("session") {
get("/me") {
val redirectTo = call.principal<UserSession>()?.let {
- User.get(it.user)?.username?.let { name ->
- "/user/$name"
- }
+ "/user/${it.user}"
} ?: "/login"
redirect(redirectTo)
}
- }
-
- get("/me/manage") {
- call.respondHtml(HttpStatusCode.OK, call.manageUserPage())
- }
-
- post("/me/manage") {
- val currentUser = call.getUser() ?: redirect("/login")
- val form = call.receiveParameters()
- val newName = form.getOrFail("name")
- if (usernameRegex.matchEntire(newName) == null)
- redirect("/me/manage?" + parametersOf("error", invalidUsernameErrorMessage).formUrlEncode())
+ get("/me/manage") {
+ call.respondHtml(HttpStatusCode.OK, call.manageUserPage())
+ }
- val newUser = currentUser.copy(
- username = form.getOrFail("name")
- )
- try {
+ post("/me/manage") {
+ val currentUser = call.getUser() ?: redirect("/login")
+ val form = call.receiveParameters()
+
+ val newUser = currentUser.copy(
+ profileName = form["name"]?.takeIf { it.isNotBlank() } ?: currentUser.profileName
+ )
User.put(newUser)
- redirect("/user/${newUser.username}")
- } catch (ex: MongoException) {
- redirect("/me/manage?" + parametersOf("error", "That username is already taken").formUrlEncode())
+ redirect("/user/${newUser.id}")
}
}
- get("/user/{name}") {
+ get("/user/{id}") {
call.respondHtml(HttpStatusCode.OK, call.userPage())
}
- get("/admiral/new") {
- call.respondHtml(HttpStatusCode.OK, call.createAdmiralPage())
- }
-
- post("/admiral/new") {
- val currentUser = call.getUserSession()?.user ?: redirect("/login")
- val form = call.receiveParameters()
-
- val newAdmiral = Admiral(
- owningUser = currentUser,
- name = form["name"]?.takeIf { it.isNotBlank() } ?: throw MissingRequestParameterException("name"),
- isFemale = form.getOrFail("sex") == "female",
- faction = Faction.valueOf(form.getOrFail("faction")),
- // TODO change to Rear Admiral
- rank = AdmiralRank.LORD_ADMIRAL
- )
- val newShips = generateFleet(newAdmiral)
+ authenticate("session") {
+ get("/admiral/new") {
+ call.respondHtml(HttpStatusCode.OK, call.createAdmiralPage())
+ }
- coroutineScope {
- launch { Admiral.put(newAdmiral) }
- newShips.forEach {
- launch { ShipInDrydock.put(it) }
+ post("/admiral/new") {
+ val currentUser = call.getUserSession()?.user ?: redirect("/login")
+ val form = call.receiveParameters()
+
+ val newAdmiral = Admiral(
+ owningUser = currentUser,
+ name = form["name"]?.takeIf { it.isNotBlank() } ?: throw MissingRequestParameterException("name"),
+ isFemale = form.getOrFail("sex") == "female",
+ faction = Faction.valueOf(form.getOrFail("faction")),
+ // TODO change to Rear Admiral
+ rank = AdmiralRank.LORD_ADMIRAL
+ )
+ val newShips = generateFleet(newAdmiral)
+
+ coroutineScope {
+ launch { Admiral.put(newAdmiral) }
+ newShips.forEach {
+ launch { ShipInDrydock.put(it) }
+ }
}
+
+ redirect("/admiral/${newAdmiral.id}")
}
-
- redirect("/admiral/${newAdmiral.id}")
}
get("/admiral/{id}") {
call.respondHtml(HttpStatusCode.OK, call.admiralPage())
}
- get("/admiral/{id}/manage") {
- call.respondHtml(HttpStatusCode.OK, call.manageAdmiralPage())
- }
-
- post("/admiral/{id}/manage") {
- val currentUser = call.getUserSession()?.user
- val admiralId = call.parameters["id"]?.let { Id<Admiral>(it) }!!
- val admiral = Admiral.get(admiralId)!!
-
- if (admiral.owningUser != currentUser) throw IllegalArgumentException()
+ authenticate("session") {
+ get("/admiral/{id}/manage") {
+ call.respondHtml(HttpStatusCode.OK, call.manageAdmiralPage())
+ }
- val form = call.receiveParameters()
- val newAdmiral = admiral.copy(
- name = form["name"]?.takeIf { it.isNotBlank() } ?: admiral.name,
- isFemale = form["sex"] == "female"
- )
+ post("/admiral/{id}/manage") {
+ val currentUser = call.getUserSession()?.user
+ val admiralId = call.parameters["id"]?.let { Id<Admiral>(it) }!!
+ val admiral = Admiral.get(admiralId)!!
+
+ if (admiral.owningUser != currentUser) throw ForbiddenException()
+
+ val form = call.receiveParameters()
+ val newAdmiral = admiral.copy(
+ name = form["name"]?.takeIf { it.isNotBlank() } ?: admiral.name,
+ isFemale = form["sex"] == "female"
+ )
+
+ Admiral.put(newAdmiral)
+ redirect("/admiral/$admiralId")
+ }
- Admiral.put(newAdmiral)
- redirect("/admiral/$admiralId")
- }
-
- get("/admiral/{id}/delete") {
- call.respondHtml(HttpStatusCode.OK, call.deleteAdmiralConfirmPage())
- }
-
- post("/admiral/{id}/delete") {
- val currentUser = call.getUserSession()?.user
- val admiralId = call.parameters["id"]?.let { Id<Admiral>(it) }!!
- val admiral = Admiral.get(admiralId)!!
+ get("/admiral/{id}/delete") {
+ call.respondHtml(HttpStatusCode.OK, call.deleteAdmiralConfirmPage())
+ }
- if (admiral.owningUser != currentUser) throw IllegalArgumentException()
+ post("/admiral/{id}/delete") {
+ val currentUser = call.getUserSession()?.user
+ val admiralId = call.parameters["id"]?.let { Id<Admiral>(it) }!!
+ val admiral = Admiral.get(admiralId)!!
+
+ if (admiral.owningUser != currentUser) throw ForbiddenException()
+
+ Admiral.del(admiralId)
+ redirect("/me")
+ }
- Admiral.del(admiralId)
- redirect("/me")
- }
-
- get("/logout") {
- call.getUserSession()?.let { sess ->
- launch {
- val newTime = System.currentTimeMillis() - 100
- UserSession.update(UserSession::id eq sess.id, setValue(UserSession::expirationMillis, newTime))
+ get("/logout") {
+ call.getUserSession()?.let { sess ->
+ launch {
+ val newTime = System.currentTimeMillis() - 100
+ UserSession.update(UserSession::id eq sess.id, setValue(UserSession::expirationMillis, newTime))
+ }
}
+
+ call.sessions.clear<Id<UserSession>>()
+ redirect("/")
}
- call.sessions.clear<Id<UserSession>>()
- redirect("/")
- }
-
- get("/logout/{id}") {
- val id = Id<UserSession>(call.parameters.getOrFail("id"))
- call.getUserSession()?.let { sess ->
- launch {
- val newTime = System.currentTimeMillis() - 100
- UserSession.update(and(UserSession::id eq id, UserSession::user eq sess.user), setValue(UserSession::expirationMillis, newTime))
+ get("/logout/{id}") {
+ val id = Id<UserSession>(call.parameters.getOrFail("id"))
+ call.getUserSession()?.let { sess ->
+ launch {
+ val newTime = System.currentTimeMillis() - 100
+ UserSession.update(and(UserSession::id eq id, UserSession::user eq sess.user), setValue(UserSession::expirationMillis, newTime))
+ }
}
+
+ redirect("/me/manage")
}
- redirect("/me/manage")
- }
-
- get("/logout-all") {
- call.getUserSession()?.let { sess ->
- launch {
- val newTime = System.currentTimeMillis() - 100
- UserSession.update(and(UserSession::user eq sess.user, UserSession::id ne sess.id), setValue(UserSession::expirationMillis, newTime))
+ get("/logout-all") {
+ call.getUserSession()?.let { sess ->
+ launch {
+ val newTime = System.currentTimeMillis() - 100
+ UserSession.update(and(UserSession::user eq sess.user, UserSession::id ne sess.id), setValue(UserSession::expirationMillis, newTime))
+ }
}
+
+ redirect("/me/manage")
}
-
- redirect("/me/manage")
}
-
currentProvider.installRouting(this)
}
}
validate { credentials ->
val originAddress = request.origin.remoteHost
val userAgent = request.userAgent()
- if (userAgent != null && credentials.name.isValidUsername() && credentials.password == PASSWORD_VALUE) {
- val user = User.locate(User::username eq credentials.name)
- ?: User(username = credentials.name).also {
+ if (userAgent != null && credentials.name.isNotBlank() && credentials.password == PASSWORD_VALUE) {
+ val user = User.locate(User::discordId eq credentials.name)
+ ?: User(
+ discordId = credentials.name,
+ discordName = "",
+ discordDiscriminator = "0000",
+ profileName = "Test User"
+ ).also {
User.put(it)
}
challenge { credentials ->
val errorMsg = if (call.request.userAgent() == null)
"User-Agent must be specified when logging in. Are you using some weird API client?"
- else if (credentials == null)
+ else if (credentials == null || credentials.name.isBlank())
"A username must be provided."
- else if (!credentials.name.isValidUsername())
- invalidUsernameErrorMessage
else if (credentials.password != PASSWORD_VALUE)
"Password is incorrect."
else
autoComplete = false
required = true
- minLength = "2"
- maxLength = "32"
- title = usernameTooltip
- pattern = usernameRegexStr
}
errorMsg?.let { msg ->
p {
}
}
}
+
+class ProductionAuthProvider(val discordLogin: DiscordLogin) : AuthProvider {
+ private val httpClient = HttpClient(Apache)
+
+ override fun installAuth(conf: Authentication.Configuration) {
+ conf.oauth("auth-oauth-discord") {
+ urlProvider = { discordLogin.redirectUrlOrigin.removeSuffix("/") + "/login/discord/callback" }
+ providerLookup = {
+ OAuthServerSettings.OAuth2ServerSettings(
+ name = "discord",
+ authorizeUrl = "https://discord.com/api/oauth2/authorize",
+ accessTokenUrl = "https://discord.com/api/oauth2/token",
+ requestMethod = HttpMethod.Post,
+ clientId = discordLogin.clientId,
+ clientSecret = discordLogin.clientSecret,
+ defaultScopes = listOf("identify"),
+ nonceManager = StateParameterManager
+ )
+ }
+ client = httpClient
+ }
+ }
+
+ override fun installRouting(conf: Routing) {
+ with(conf) {
+ get("/login") {
+ val errorMsg = call.request.queryParameters["error"]
+
+ call.respondHtml(HttpStatusCode.OK, page("Login with Discord", call.standardNavBar(), null) {
+ section {
+ if (errorMsg != null)
+ p {
+ style = "color:#d22"
+ +errorMsg
+ }
+ p {
+ style = "text-align:center"
+ a(href = "/login/discord") { +"Continue to Discord" }
+ }
+ }
+ })
+ }
+
+ authenticate("auth-oauth-discord") {
+ get("/login/discord") {
+ // Redirects to 'authorizeUrl' automatically
+ }
+
+ get("/login/discord/callback") {
+ val userAgent = call.request.userAgent() ?: throw ForbiddenException()
+ val principal: OAuthAccessTokenResponse.OAuth2 = call.principal() ?: redirect("/login")
+ //call.sessions.set(UserSession(principal?.accessToken.toString()))
+ val userInfoJson = httpClient.get<String>("https://discord.com/api/users/@me") {
+ headers {
+ append(HttpHeaders.Authorization, "Bearer ${principal.accessToken}")
+ }
+ }
+
+ val userInfo = JsonConfigCodec.parseToJsonElement(userInfoJson) as? JsonObject ?: redirect("/login")
+ val discordId = (userInfo["id"] as? JsonPrimitive)?.content ?: redirect("/login")
+ val discordUsername = (userInfo["username"] as? JsonPrimitive)?.content ?: redirect("/login")
+ val discordDiscriminator = (userInfo["discriminator"] as? JsonPrimitive)?.content ?: redirect("/login")
+
+ val user = User.locate(User::discordId eq discordId)?.copy(
+ discordName = discordUsername,
+ discordDiscriminator = discordDiscriminator
+ ) ?: User(
+ discordId = discordId,
+ discordName = discordUsername,
+ discordDiscriminator = discordDiscriminator,
+ profileName = discordUsername
+ )
+
+ val userSession = UserSession(
+ user = user.id,
+ clientAddresses = listOf(call.request.origin.host),
+ userAgent = userAgent,
+ expirationMillis = System.currentTimeMillis() + 86_400_000
+ )
+
+ launch {
+ User.put(user)
+ UserSession.put(userSession)
+ }
+
+ call.sessions.set(userSession.id)
+ redirect("/me")
+ }
+ }
+ }
+ }
+}
+
+object StateParameterManager : NonceManager {
+ private val nonces = mutableSetOf<String>()
+
+ override suspend fun newNonce(): String {
+ return createNonce().also { nonces += it }
+ }
+
+ override suspend fun verifyNonce(nonce: String): Boolean {
+ return nonces.remove(nonce)
+ }
+}
import org.litote.kmongo.and
import org.litote.kmongo.eq
import org.litote.kmongo.or
+import starshipfights.ForbiddenException
import starshipfights.auth.getUser
import starshipfights.auth.getUserSession
import starshipfights.data.Id
import starshipfights.data.admiralty.*
import starshipfights.data.auth.User
import starshipfights.data.auth.UserSession
-import starshipfights.data.auth.usernameRegexStr
-import starshipfights.data.auth.usernameTooltip
import starshipfights.game.Faction
import starshipfights.game.GlobalSide
import starshipfights.game.toUrlSlug
import java.time.Instant
suspend fun ApplicationCall.userPage(): HTML.() -> Unit {
- val username = parameters["name"]!!
- val user = User.locate(User::username eq username)!!
+ val username = Id<User>(parameters["id"]!!)
+ val user = User.get(username)!!
val isCurrentUser = user.id == getUserSession()?.user
val admirals = Admiral.select(Admiral::owningUser eq user.id).toList()
return page(
- username, standardNavBar(), if (isCurrentUser)
+ user.profileName, standardNavBar(), if (isCurrentUser)
PageNavSidebar(
listOf(
NavLink("/admiral/new", "New Admiral"),
}
) {
section {
- h1 { +username }
+ h1 { +user.profileName }
p {
- +"This user's username is $username!"
+ +"This user's profile name is ${user.profileName}!"
}
if (isCurrentUser)
form(method = FormMethod.post, action = "/me/manage") {
h3 {
label {
- htmlFor = "username"
- +"Username"
+ htmlFor = "name"
+ +"Profile Name"
}
}
textInput(name = "name") {
required = true
- value = currentUser.username
+ value = currentUser.profileName
autoComplete = false
required = true
- minLength = "2"
- maxLength = "32"
- title = usernameTooltip
- pattern = usernameRegexStr
}
request.queryParameters["error"]?.let { errorMsg ->
p {
- style = "color:#d33"
+ style = "color:#d22"
+errorMsg
}
}
Triple(admiral.await(), ships.await(), records.await())
}
- val admiralOwner = User.get(admiral.owningUser)!!.username
-
val recordRoles = records.mapNotNull {
when (admiralId) {
it.hostAdmiral -> GlobalSide.HOST
return page(
admiral.fullName, standardNavBar(), PageNavSidebar(
listOf(
- NavLink("/user/${admiralOwner}", "Back to User")
+ NavLink("/user/${admiral.owningUser}", "Back to User")
) + if (currentUser == admiral.owningUser)
listOf(
NavLink("/admiral/${admiral.id}/manage", "Manage Admiral")
val admiralId = parameters["id"]?.let { Id<Admiral>(it) }!!
val admiral = Admiral.get(admiralId)!!
- if (admiral.owningUser != currentUser) throw IllegalArgumentException()
+ if (admiral.owningUser != currentUser) throw ForbiddenException()
return page(
"Managing ${admiral.name}", standardNavBar(), PageNavSidebar(
val admiralId = parameters["id"]?.let { Id<Admiral>(it) }!!
val admiral = Admiral.get(admiralId)!!
- if (admiral.owningUser != currentUser) throw IllegalArgumentException()
+ if (admiral.owningUser != currentUser) throw ForbiddenException()
return page(
"Are You Sure?", null, null