From 71549f185ec08531108e3f1592af1e844b76bb8f Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Mon, 26 Feb 2024 09:54:28 +0100 Subject: [PATCH] Auth still in progress --- api-webapp/pom.xml | 6 + .../org/jeudego/pairgoth/api/ApiHandler.kt | 2 +- .../org/jeudego/pairgoth/api/AuthChallenge.kt | 15 --- .../jeudego/pairgoth/api/PairingHandler.kt | 2 +- .../org/jeudego/pairgoth/api/PlayerHandler.kt | 2 +- .../org/jeudego/pairgoth/api/TeamHandler.kt | 2 +- .../org/jeudego/pairgoth/api/TokenHandler.kt | 127 +++++++++++++----- .../jeudego/pairgoth/api/TournamentHandler.kt | 2 +- .../org/jeudego/pairgoth/server/ApiServlet.kt | 28 +++- client.sh | 12 +- debug-client.sh | 10 ++ debug-server.sh | 4 + .../jeudego/pairgoth/util/AESCryptograph.kt | 1 - .../org/jeudego/pairgoth/util/Randomizer.kt | 6 + .../kotlin/org/jeudego/pairgoth/web/Shared.kt | 18 +++ pom.xml | 4 +- server.sh | 8 +- standalone.sh | 2 +- .../org/jeudego/pairgoth/oauth/OAuthHelper.kt | 3 +- .../pairgoth/util/CredentialsChecker.kt | 6 +- .../org/jeudego/pairgoth/view/ApiTool.kt | 14 +- .../org/jeudego/pairgoth/web/ApiServlet.kt | 5 +- .../org/jeudego/pairgoth/web/AuthFilter.kt | 84 +++++++++++- .../org/jeudego/pairgoth/web/LoginServlet.kt | 4 +- .../org/jeudego/pairgoth/web/LogoutServlet.kt | 6 +- view-webapp/src/main/webapp/js/api.js | 5 +- view-webapp/src/main/webapp/tour.html | 1 + 27 files changed, 295 insertions(+), 84 deletions(-) delete mode 100644 api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/AuthChallenge.kt create mode 100755 debug-client.sh create mode 100755 debug-server.sh create mode 100644 pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/Randomizer.kt create mode 100644 pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/web/Shared.kt diff --git a/api-webapp/pom.xml b/api-webapp/pom.xml index 4e54e7a..9948f76 100644 --- a/api-webapp/pom.xml +++ b/api-webapp/pom.xml @@ -65,6 +65,7 @@ ${pairgoth.api.port} + ${pairgoth.auth} ${pairgoth.env} ${pairgoth.version} ${pairgoth.api.external.url} @@ -164,6 +165,11 @@ 2.13.0 + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + commons-codec commons-codec diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt index 4d84c5c..5bd1047 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt @@ -21,7 +21,7 @@ interface ApiHandler { notImplemented() } - fun post(request: HttpServletRequest, response: HttpServletResponse): Json { + fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { notImplemented() } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/AuthChallenge.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/AuthChallenge.kt deleted file mode 100644 index 72e0d98..0000000 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/AuthChallenge.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.jeudego.pairgoth.api - -class AuthChallenge { - companion object { - private val validChars = ('a'..'z') + ('A'..'Z') + ('0'..'9') - private fun randomString(length: Int) = CharArray(length) { validChars.random() }.concatToString() - private val lifespan = 30000L - } - private val _value = randomString(64) - private val _gen = System.currentTimeMillis() - - val value get() = - if (System.currentTimeMillis() - _gen > lifespan) null - else _value -} diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt index 84a02ea..3c105ee 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt @@ -32,7 +32,7 @@ object PairingHandler: PairgothApiHandler { ) } - override fun post(request: HttpServletRequest, response: HttpServletResponse): Json { + override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { val tournament = getTournament(request) val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played") diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt index 26f7b14..c4e1e10 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt @@ -19,7 +19,7 @@ object PlayerHandler: PairgothApiHandler { } } - override fun post(request: HttpServletRequest, response: HttpServletResponse): Json { + override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { val tournament = getTournament(request) val payload = getObjectPayload(request) val player = Player.fromJson(payload) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt index 51aaed6..0f6ce87 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TeamHandler.kt @@ -19,7 +19,7 @@ object TeamHandler: PairgothApiHandler { } } - override fun post(request: HttpServletRequest, response: HttpServletResponse): Json { + override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { val tournament = getTournament(request) if (tournament !is TeamTournament) badRequest("tournament is not a team tournament") val payload = getObjectPayload(request) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TokenHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TokenHandler.kt index a4d6e94..d6ef4a9 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TokenHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TokenHandler.kt @@ -1,55 +1,120 @@ package org.jeudego.pairgoth.api +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import com.republicate.kson.Json +import org.jeudego.pairgoth.server.ApiServlet import org.jeudego.pairgoth.util.AESCryptograph import org.jeudego.pairgoth.util.Cryptograph +import org.jeudego.pairgoth.util.Randomizer +import org.jeudego.pairgoth.web.sharedSecret +import java.util.Random +import java.util.concurrent.TimeUnit import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -import javax.servlet.http.HttpSession -class TokenHandler: ApiHandler { - companion object { - const val AUTH_KEY = "pairgoth-auth" - const val CHALLENGE_KEY = "pairgoth-challenge" - private val cryptograph: Cryptograph = AESCryptograph().apply { - init("78659783ed8ccc0e") +object TokenHandler: ApiHandler { + + const val AUTH_HEADER = "Authorization" + const val AUTH_PREFIX = "Bearer" + + private val cryptograph = AESCryptograph().apply { init(sharedSecret) } + + private data class AuthorizationPayload( + val sessionId: String, + val accessKey: String, + val userInfos: Json + ) + + private fun getAuthorizationPayload(request: HttpServletRequest): AuthorizationPayload? { + val authorize = request.getHeader(AUTH_HEADER) as String? + if (authorize != null && authorize.startsWith("$AUTH_PREFIX ")) { + val bearer = authorize.substring(AUTH_PREFIX.length + 1) + val clear = cryptograph.webDecrypt(bearer) + val parts = clear.split(':') + if (parts.size == 2) { + val sessionId = parts[0] + val accessKey = parts[1] + val accessPayload = accesses.getIfPresent(accessKey) + if (accessPayload != null && sessionId == accessPayload.getString("session")) { + return AuthorizationPayload(sessionId, accessKey, accessPayload) + } + } } + return null } + + fun getLoggedUser(request: HttpServletRequest) = getAuthorizationPayload(request)?.userInfos + override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? { - val auth = request.session.getAttribute(AUTH_KEY) as String? - if (auth == null) { + if (getLoggedUser(request) == null) { failed(request, response) return null } else { - return Json.Object( - "success" to true, - "auth" to auth - ) - } - } - override fun post(request: HttpServletRequest, response: HttpServletResponse): Json { - val auth = getObjectPayload(request) - val answer = auth.getString("answer") - val challenge = request.session.getAttribute(CHALLENGE_KEY) as AuthChallenge? - if (answer == null || challenge == null) { - failed(request, response) - } else { - val parts = cryptograph.webDecrypt(answer).split(":") - if (parts.size != 2) + return Json.Object("success" to true) } } + override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { + val auth = getObjectPayload(request) + val session = auth.getString("session") + val challenge = challenges.getIfPresent(session) + challenges.invalidate(session) + if (challenge != null) { + val email = auth.getString("email") + val signature = auth.getString("signature") + val expectedSignature = cryptograph.webEncrypt( + "${ + session + }:${ + challenge + }:${ + email + }" + ) + if (signature == expectedSignature) { + val accessKey = Randomizer.randomString(32) + accesses.put(accessKey, Json.Object( + "session" to session, + "email" to email + )) + return Json.Object("token" to accessKey) + } + } + failed(request, response) + return null + } + override fun delete(request: HttpServletRequest, response: HttpServletResponse): Json { - request.session.removeAttribute(AUTH_KEY) + getAuthorizationPayload(request)?.let { payload -> + accesses.invalidate(payload.accessKey) + } return Json.Object("success" to true) } private fun failed(request: HttpServletRequest, response: HttpServletResponse) { - val session = request.session - val challenge = AuthChallenge() - session.setAttribute(CHALLENGE_KEY, challenge) - response.addHeader("WWW-Authenticate", challenge.value) - response.status = HttpServletResponse.SC_UNAUTHORIZED - response.writer.println(Json.Object("status" to "failed", "message" to "unauthorized")) + val authPayload = getAuthorizationPayload(request) + if (authPayload != null && authPayload.sessionId.isNotEmpty()) { + val challenge = Randomizer.randomString(32) + challenges.put(authPayload.sessionId, challenge) + response.addHeader("WWW-Authenticate", challenge) + response.status = HttpServletResponse.SC_UNAUTHORIZED + response.writer.println(Json.Object("status" to "failed", "message" to "unauthorized")) + } else { + response.status = HttpServletResponse.SC_BAD_REQUEST + } } + + // a short-lived cache for sessionid <--> challenge association + private val challenges: Cache = Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .maximumSize(100) + .build() + + // a long-lived cache for access key <--> user association + private val accesses: Cache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.DAYS) + .maximumSize(100) + .build() + } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt index a50b37f..d9abce3 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt @@ -36,7 +36,7 @@ object TournamentHandler: PairgothApiHandler { } } - override fun post(request: HttpServletRequest, response: HttpServletResponse): Json { + override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) { is Json.Object -> Tournament.fromJson(getObjectPayload(request)) is Element -> OpenGotha.import(payload) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt index f37dbb1..df6ba1c 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/server/ApiServlet.kt @@ -1,5 +1,7 @@ package org.jeudego.pairgoth.server +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine import com.republicate.kson.Json import org.apache.commons.io.input.BOMInputStream import org.jeudego.pairgoth.api.ApiHandler @@ -8,24 +10,29 @@ import org.jeudego.pairgoth.api.PlayerHandler import org.jeudego.pairgoth.api.ResultsHandler import org.jeudego.pairgoth.api.StandingsHandler import org.jeudego.pairgoth.api.TeamHandler +import org.jeudego.pairgoth.api.TokenHandler import org.jeudego.pairgoth.api.TournamentHandler +import org.jeudego.pairgoth.util.AESCryptograph import org.jeudego.pairgoth.util.Colorizer.blue import org.jeudego.pairgoth.util.Colorizer.green import org.jeudego.pairgoth.util.Colorizer.red import org.jeudego.pairgoth.util.XmlUtils import org.jeudego.pairgoth.util.parse import org.jeudego.pairgoth.util.toString +import org.jeudego.pairgoth.web.sharedSecret import org.slf4j.LoggerFactory import org.w3c.dom.Element import java.io.IOException import java.io.InputStreamReader import java.util.* +import java.util.concurrent.TimeUnit import java.util.concurrent.locks.ReadWriteLock import java.util.concurrent.locks.ReentrantReadWriteLock import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse + class ApiServlet: HttpServlet() { public override fun doGet(request: HttpServletRequest, response: HttpServletResponse) { @@ -49,7 +56,11 @@ class ApiServlet: HttpServlet() { val requestLock = if (request.method == "GET") lock.readLock() else lock.writeLock() try { requestLock.lock() - doProtectedRequest(request, response) + if (checkAuthorization(request, response)) { + doProtectedRequest(request, response) + } else { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED) + } } finally { requestLock.unlock() } @@ -85,7 +96,8 @@ class ApiServlet: HttpServlet() { val handler = when (entity) { "tour" -> - when (subEntity) { + if ("token" == selector) TokenHandler + else when (subEntity) { null -> TournamentHandler "part" -> PlayerHandler "pair" -> PairingHandler @@ -274,11 +286,19 @@ class ApiServlet: HttpServlet() { } } + private fun checkAuthorization(request: HttpServletRequest, response: HttpServletResponse): Boolean { + val auth = WebappManager.getMandatoryProperty("auth") + return auth == "none" || + "/api/tour/token" == request.requestURI || + TokenHandler.getLoggedUser(request)?.also { + request.setAttribute(USER_KEY, it) + } != null + } + companion object { private var logger = LoggerFactory.getLogger("api") private const val EXPECTED_CHARSET = "utf8" - const val AUTH_HEADER = "Authorization" - const val AUTH_PREFIX = "Bearer" + const val USER_KEY = "pairgoth-user" fun isJson(mimeType: String) = "text/json" == mimeType || "application/json" == mimeType || mimeType.endsWith("+json") fun isXml(mimeType: String) = "text/xml" == mimeType || "application/xml" == mimeType || mimeType.endsWith("+xml") } diff --git a/client.sh b/client.sh index c9801a1..c9af334 100755 --- a/client.sh +++ b/client.sh @@ -1,10 +1,6 @@ -#!/bin/bash +#!/bin/sh -trap 'kill $CSSWATCH; exit' INT -( cd view-webapp; ./csswatch.sh ) & -CSSWATCH=$! +# debug version +# mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar -export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006" -#mvn --projects view-webapp -Dpairgoth.api.url=http://localhost:8085/api/ package jetty:run -mvn -DskipTests=true --projects view-webapp package jetty:run -kill $CSSWATCH +mvn -DskipTests=true package && java -Dpairgoth.mode=client -jar application/target/pairgoth-engine.jar diff --git a/debug-client.sh b/debug-client.sh new file mode 100755 index 0000000..ad70934 --- /dev/null +++ b/debug-client.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +trap 'kill $CSSWATCH; exit' INT +( cd view-webapp; ./csswatch.sh ) & +CSSWATCH=$! + +export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006" +#mvn --projects view-webapp -Dpairgoth.api.url=http://localhost:8085/api/ package jetty:run +mvn -DskipTests=true --projects view-webapp package jetty:run -Dpairgoth.mode=client +kill $CSSWATCH diff --git a/debug-server.sh b/debug-server.sh new file mode 100755 index 0000000..d6b0bcf --- /dev/null +++ b/debug-server.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" +mvn -DskipTests=true --projects api-webapp package jetty:run -Dpairgoth.mode=server diff --git a/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt index 691fde9..e76c1d6 100644 --- a/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt +++ b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/AESCryptograph.kt @@ -52,6 +52,5 @@ class AESCryptograph : Cryptograph { companion object { private val CIPHER = "AES/ECB/PKCS5Padding" private val ALGORITHM = "AES" - } } diff --git a/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/Randomizer.kt b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/Randomizer.kt new file mode 100644 index 0000000..8a264f7 --- /dev/null +++ b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/util/Randomizer.kt @@ -0,0 +1,6 @@ +package org.jeudego.pairgoth.util + +object Randomizer { + private val validChars = ('a'..'z') + ('A'..'Z') + ('0'..'9') + fun randomString(length: Int) = CharArray(length) { validChars.random() }.concatToString() +} diff --git a/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/web/Shared.kt b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/web/Shared.kt new file mode 100644 index 0000000..44b907c --- /dev/null +++ b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/web/Shared.kt @@ -0,0 +1,18 @@ +package org.jeudego.pairgoth.web + +import org.jeudego.pairgoth.util.Randomizer +import java.lang.RuntimeException + + +// a randomly generated secret shared by the API and View webapps +val sharedSecret: String by lazy { + BaseWebappManager.properties.getProperty("auth.shared_secret") ?: when (BaseWebappManager.properties.getProperty("mode")) { + "standalone" -> Randomizer.randomString(16) + else -> when (BaseWebappManager.properties.getProperty("auth")) { + "none" -> " ".repeat(16) + else -> throw RuntimeException("missing property auth.shared_secret") + } + }.also { + if (it.length != 16) throw RuntimeException("shared secret must be 16 ascii chars long") + } +} diff --git a/pom.xml b/pom.xml index 15348a1..50264e7 100644 --- a/pom.xml +++ b/pom.xml @@ -71,17 +71,17 @@ localhost 8080 / - ${pairgoth.webapp.protocol}://${pairgoth.webapp.host}:${pairgoth.webapp.port}${pairgoth.webapp.context} http localhost 8085 /api ${pairgoth.api.protocol}://${pairgoth.api.host}:${pairgoth.api.port}${pairgoth.api.context} + standalone file tournamentfiles none - this_should_be_overriden_with_a_command_line_option + this_should_be_overriden pairtogh diff --git a/server.sh b/server.sh index eeccd40..3088fa4 100755 --- a/server.sh +++ b/server.sh @@ -1,4 +1,6 @@ -#!/bin/bash +#!/bin/sh -export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" -mvn -DskipTests=true --projects api-webapp package jetty:run +# debug version +# mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar + +mvn -DskipTests=true package && java -Dpairgoth.mode=server -jar application/target/pairgoth-engine.jar diff --git a/standalone.sh b/standalone.sh index 8571b14..8d12f47 100755 --- a/standalone.sh +++ b/standalone.sh @@ -3,4 +3,4 @@ # debug version # mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar -mvn -DskipTests=true package && java -jar application/target/pairgoth-engine.jar +mvn -DskipTests=true package && java -Dpairgoth.mode=standalone -jar application/target/pairgoth-engine.jar diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt index 27665cc..5b28182 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/oauth/OAuthHelper.kt @@ -10,6 +10,7 @@ import org.apache.http.NameValuePair import org.jeudego.pairgoth.util.AESCryptograph import org.jeudego.pairgoth.util.ApiClient.JsonApiClient import org.jeudego.pairgoth.util.Cryptograph +import org.jeudego.pairgoth.util.Randomizer import org.slf4j.Logger import org.slf4j.LoggerFactory import java.io.IOException @@ -55,7 +56,7 @@ abstract class OAuthHelper { companion object { protected var logger: Logger = LoggerFactory.getLogger("oauth") private val cryptograph: Cryptograph = AESCryptograph().apply { - init("0efd28fb53cbac42") + init(Randomizer.randomString(16)) } } } \ No newline at end of file diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/CredentialsChecker.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/CredentialsChecker.kt index 99bffff..a9cdf69 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/CredentialsChecker.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/util/CredentialsChecker.kt @@ -15,11 +15,11 @@ object CredentialsChecker { val sha256 = hasher.digest(password.toByteArray(StandardCharsets.UTF_8)).toHexString() DriverManager.getConnection("jdbc:sqlite:$CREDENTIALS_DB").use { conn -> val rs = - conn.prepareStatement("SELECT 1 FROM cred WHERE email = ? AND password = ?").apply { + conn.prepareStatement("SELECT id FROM cred WHERE email = ? AND password = ?").apply { setString(1, email) setString(2, password) }.executeQuery() - return if (rs.next()) Json.Object("email" to email) else null + return if (rs.next()) Json.Object("id" to "${rs.getInt("id")}", "email" to email) else null } } @@ -27,7 +27,7 @@ object CredentialsChecker { fun initDatabase() { if (!File(CREDENTIALS_DB).exists()) { DriverManager.getConnection("jdbc:sqlite:$CREDENTIALS_DB").use { conn -> - conn.createStatement().executeUpdate("CREATE TABLE cred (email VARCHAR(200) UNIQUE NOT NULL, password VARCHAR(200) NOT NULL)") + conn.createStatement().executeUpdate("CREATE TABLE cred (id INTEGER PRIMARY KEY AUTOINCREMENT, email VARCHAR(200) UNIQUE NOT NULL, password VARCHAR(200) NOT NULL)") } } } diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/ApiTool.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/ApiTool.kt index 5a66066..7bcf508 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/ApiTool.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/view/ApiTool.kt @@ -6,8 +6,10 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.internal.EMPTY_REQUEST +import org.jeudego.pairgoth.web.AuthFilter import org.jeudego.pairgoth.web.WebappManager import org.slf4j.LoggerFactory +import javax.servlet.http.HttpServletRequest class ApiTool { companion object { @@ -19,8 +21,18 @@ class ApiTool { } val logger = LoggerFactory.getLogger("api") } + private lateinit var request: HttpServletRequest + fun setRequest(req: HttpServletRequest) { + request = req + } + private fun getBearer() = AuthFilter.getBearer(request) + private val client = OkHttpClient() - private fun prepare(url: String) = Request.Builder().url("$apiRoot$url").header("Accept", JSON) + private fun prepare(url: String) = + Request.Builder().url("$apiRoot$url") + .header("Accept", JSON) + .header("Authorization", "Bearer ${getBearer()}") + private fun Json.toRequestBody() = toString().toRequestBody(JSON.toMediaType()) private fun Request.Builder.process(): Json { try { diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt index eeb5c61..c1a21b5 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt @@ -2,12 +2,15 @@ package org.jeudego.pairgoth.web import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.proxy.AsyncProxyServlet; +import org.jeudego.pairgoth.view.ApiTool import javax.servlet.http.HttpServletRequest class ApiServlet : AsyncProxyServlet() { override fun addProxyHeaders(clientRequest: HttpServletRequest, proxyRequest: Request) { - // proxyRequest.header("X-EGC-User", some user id...) + AuthFilter.getBearer(clientRequest)?.let { bearer -> + proxyRequest.header("Authorization", "Bearer $bearer") + } } override fun rewriteTarget(clientRequest: HttpServletRequest): String { diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/AuthFilter.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/AuthFilter.kt index 3fbbfb6..54cac0e 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/AuthFilter.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/AuthFilter.kt @@ -1,6 +1,16 @@ package org.jeudego.pairgoth.web +import com.republicate.kson.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import org.jeudego.pairgoth.oauth.OauthHelperFactory +import org.jeudego.pairgoth.util.AESCryptograph +import org.jeudego.pairgoth.view.ApiTool +import org.slf4j.LoggerFactory +import java.nio.charset.StandardCharsets +import java.security.MessageDigest import javax.servlet.Filter import javax.servlet.FilterChain import javax.servlet.FilterConfig @@ -36,12 +46,13 @@ class AuthFilter: Filter { val helper = OauthHelperFactory.getHelper(provider) val accessToken = helper.getAccessToken(request.session.id, request.getParameter("code") ?: "") val user = helper.getUserInfos(accessToken) - request.session.setAttribute("logged", user) + handleSuccessfulLogin(req, user) + request.session.setAttribute(SESSION_KEY_USER, user) response.sendRedirect("/index") return } - if (auth == "none" || whitelisted(uri) || forwarded || session?.getAttribute("logged") != null) { + if (auth == "none" || whitelisted(uri) || forwarded || session?.getAttribute(SESSION_KEY_USER) != null) { chain.doFilter(req, resp) } else { // TODO - protection against brute force attacks @@ -54,6 +65,14 @@ class AuthFilter: Filter { } companion object { + const val SESSION_KEY_USER = "logged" + const val SESSION_KEY_API_TOKEN = "pairgoth-api-token" + + private val logger = LoggerFactory.getLogger("auth") + private val cryptograph = AESCryptograph().apply { init(sharedSecret) } + private val hasher = MessageDigest.getInstance("SHA-256") + private val client = OkHttpClient() + private val whitelist = setOf( "/login", "/index-ffg", @@ -66,5 +85,66 @@ class AuthFilter: Filter { val nolangUri = uri.replace(Regex("^/../"), "/") return whitelist.contains(nolangUri) } + + fun handleSuccessfulLogin(req: HttpServletRequest, user: Json.Object) { + logger.info("successful login for $user") + req.session.setAttribute(SESSION_KEY_USER, user) + fetchApiToken(req, user)?.also { token -> + req.session.setAttribute(SESSION_KEY_API_TOKEN, token) + } + } + + fun fetchApiToken(req: HttpServletRequest, user: Json.Object): String? { + val challengeReq = Request.Builder().url("${ApiTool.apiRoot}tour/token") + .header("Authorization", "Bearer ${getBearer(req)}") + .build() + val challengeResp = client.newCall(challengeReq).execute() + if (challengeResp.code == HttpServletResponse.SC_UNAUTHORIZED) { + val email = user.getString("email") ?: "-" + val challenge = challengeResp.headers["WWW-Authenticate"] + if (challenge != null) { + val signature = hasher.digest( + "${ + req.session.id + }:${ + challenge + }:${ + email + }".toByteArray(StandardCharsets.UTF_8)) + val answer = Json.Object( + "session" to req.session.id, + "email" to email, + "signature" to signature + ) + val answerReq = Request.Builder().url("${ApiTool.apiRoot}tour/token").post( + answer.toString().toRequestBody(ApiTool.JSON.toMediaType()) + ).build() + val answerResp = client.newCall(answerReq).execute() + if (answerResp.isSuccessful && "json" == answerResp.body?.contentType()?.subtype) { + val payload = Json.parse(answerResp.body!!.string()) + if (payload != null && payload.isObject) { + val token = payload.asObject().getString("token") + if (token != null) return token + } + } + } + } + return null + } + + fun clearApiToken(req: HttpServletRequest) { + val deleteTokenReq = Request.Builder().url("${ApiTool.apiRoot}tour/token").delete().build() + client.newCall(deleteTokenReq).execute() + } + + fun getBearer(req: HttpServletRequest): String { + val session = req.session + return cryptograph.webEncrypt( + "${ + session.id + }:${ + session.getAttribute(SESSION_KEY_API_TOKEN) ?: "" + }") + } } } diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LoginServlet.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LoginServlet.kt index f301b90..36bbd80 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LoginServlet.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LoginServlet.kt @@ -20,7 +20,7 @@ class LoginServlet: HttpServlet() { "sesame" -> checkSesame(payload) else -> checkLoginPass(payload) } ?: throw Error("authentication failed") - req.session.setAttribute("logged", user) + AuthFilter.handleSuccessfulLogin(req, user) val ret = Json.Object("status" to "ok") resp.contentType = "application/json" resp.writer.println(ret.toString()) @@ -34,7 +34,7 @@ class LoginServlet: HttpServlet() { fun checkSesame(payload: Json.Object): Json.Object? { val expected = WebappManager.properties.getProperty("auth.sesame") ?: throw Error("sesame wrongly configured") - return if (payload.getString("sesame")?.equals(expected) == true) Json.Object("logged" to true) else null + return if (payload.getString("sesame")?.equals(expected) == true) Json.Object(AuthFilter.SESSION_KEY_USER to true) else null } fun checkLoginPass(payload: Json.Object): Json.Object? { diff --git a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LogoutServlet.kt b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LogoutServlet.kt index 8ed8075..88e1052 100644 --- a/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LogoutServlet.kt +++ b/view-webapp/src/main/kotlin/org/jeudego/pairgoth/web/LogoutServlet.kt @@ -1,6 +1,8 @@ package org.jeudego.pairgoth.web import com.republicate.kson.Json +import okhttp3.Request +import org.jeudego.pairgoth.view.ApiTool import org.slf4j.LoggerFactory import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest @@ -9,7 +11,9 @@ import javax.servlet.http.HttpServletResponse class LogoutServlet: HttpServlet() { override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { - req.session.removeAttribute("logged") + AuthFilter.clearApiToken(req) + req.session.removeAttribute(AuthFilter.SESSION_KEY_USER) + req.session.removeAttribute(AuthFilter.SESSION_KEY_API_TOKEN) val ret = Json.Object("status" to "ok") resp.contentType = "application/json" resp.writer.println(ret.toString()) diff --git a/view-webapp/src/main/webapp/js/api.js b/view-webapp/src/main/webapp/js/api.js index 0fd329f..5b0e08f 100644 --- a/view-webapp/src/main/webapp/js/api.js +++ b/view-webapp/src/main/webapp/js/api.js @@ -19,9 +19,8 @@ let headers = function(withJson) { if (withJson) { ret['Content-Type'] = 'application/json'; } - let accessToken = store('accessToken'); - if (accessToken) { - ret['Authorization'] = `Bearer ${accessToken}`; + if (apiToken) { + ret['Authorization'] = `Bearer ${apiToken}`; } return ret; }; diff --git a/view-webapp/src/main/webapp/tour.html b/view-webapp/src/main/webapp/tour.html index 897c1c9..fff7b30 100644 --- a/view-webapp/src/main/webapp/tour.html +++ b/view-webapp/src/main/webapp/tour.html @@ -45,6 +45,7 @@