From 924b31d24b51e0bad96a902309ba680702bc0df0 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Mon, 26 Feb 2024 15:09:48 +0100 Subject: [PATCH] Auth ok --- .../org/jeudego/pairgoth/api/TokenHandler.kt | 36 +++++---- .../org/jeudego/pairgoth/server/ApiServlet.kt | 2 +- client.sh | 4 +- .../kotlin/org/jeudego/pairgoth/web/Shared.kt | 2 + server.sh | 4 +- .../org/jeudego/pairgoth/oauth/OAuthHelper.kt | 5 +- .../org/jeudego/pairgoth/view/ApiTool.kt | 22 +++++- .../org/jeudego/pairgoth/web/AuthFilter.kt | 77 +++++++++++-------- .../src/main/webapp/WEB-INF/logger.properties | 1 - .../jeudego/pairgoth/application/Pairgoth.kt | 60 ++++++++++++--- .../main/resources/client.default.properties | 18 +++++ .../main/resources/server.default.properties | 10 +-- .../resources/standalone.default.properties | 18 +++++ 13 files changed, 191 insertions(+), 68 deletions(-) delete mode 100644 view-webapp/src/main/webapp/WEB-INF/logger.properties create mode 100644 webserver/src/main/resources/client.default.properties create mode 100644 webserver/src/main/resources/standalone.default.properties 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 d6ef4a9..dfe369f 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 @@ -8,6 +8,9 @@ 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 org.jeudego.pairgoth.web.toHex +import java.nio.charset.StandardCharsets +import java.security.MessageDigest import java.util.Random import java.util.concurrent.TimeUnit import javax.servlet.http.HttpServletRequest @@ -18,6 +21,7 @@ object TokenHandler: ApiHandler { const val AUTH_HEADER = "Authorization" const val AUTH_PREFIX = "Bearer" + private val hasher = MessageDigest.getInstance("SHA-256") private val cryptograph = AESCryptograph().apply { init(sharedSecret) } private data class AuthorizationPayload( @@ -26,19 +30,24 @@ object TokenHandler: ApiHandler { val userInfos: Json ) - private fun getAuthorizationPayload(request: HttpServletRequest): AuthorizationPayload? { + private fun parseAuthorizationHeader(request: HttpServletRequest): Pair? { 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 Pair(parts[0], parts[1]) + } + } + return null + } + + private fun getAuthorizationPayload(request: HttpServletRequest): AuthorizationPayload? { + parseAuthorizationHeader(request)?.let { (sessionId, accessKey) -> + val accessPayload = accesses.getIfPresent(accessKey) + if (accessPayload != null && sessionId == accessPayload.getString("session")) { + return AuthorizationPayload(sessionId, accessKey, accessPayload) } } return null @@ -63,15 +72,15 @@ object TokenHandler: ApiHandler { if (challenge != null) { val email = auth.getString("email") val signature = auth.getString("signature") - val expectedSignature = cryptograph.webEncrypt( + val expectedSignature = hasher.digest( "${ session }:${ challenge }:${ email - }" - ) + }".toByteArray(StandardCharsets.UTF_8) + ).toHex() if (signature == expectedSignature) { val accessKey = Randomizer.randomString(32) accesses.put(accessKey, Json.Object( @@ -93,10 +102,11 @@ object TokenHandler: ApiHandler { } private fun failed(request: HttpServletRequest, response: HttpServletResponse) { - val authPayload = getAuthorizationPayload(request) - if (authPayload != null && authPayload.sessionId.isNotEmpty()) { + val authValues = parseAuthorizationHeader(request) + if (authValues != null && authValues.first.isNotEmpty()) { + val sessionId = authValues.first val challenge = Randomizer.randomString(32) - challenges.put(authPayload.sessionId, challenge) + challenges.put(sessionId, challenge) response.addHeader("WWW-Authenticate", challenge) response.status = HttpServletResponse.SC_UNAUTHORIZED response.writer.println(Json.Object("status" to "failed", "message" to "unauthorized")) 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 df6ba1c..d48013a 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 @@ -56,6 +56,7 @@ class ApiServlet: HttpServlet() { val requestLock = if (request.method == "GET") lock.readLock() else lock.writeLock() try { requestLock.lock() + logger.logRequest(request, !request.requestURI.contains(".") && request.requestURI.length > 1) if (checkAuthorization(request, response)) { doProtectedRequest(request, response) } else { @@ -68,7 +69,6 @@ class ApiServlet: HttpServlet() { private fun doProtectedRequest(request: HttpServletRequest, response: HttpServletResponse) { val uri = request.requestURI - logger.logRequest(request, !uri.contains(".") && uri.length > 1) var payload: Json? = null var reason = "OK" diff --git a/client.sh b/client.sh index c9af334..01f2100 100755 --- a/client.sh +++ b/client.sh @@ -1,6 +1,6 @@ #!/bin/sh # debug version -# mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar +mvn -DskipTests package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5006 -Dpairgoth.mode=client -jar application/target/pairgoth-engine.jar -mvn -DskipTests=true package && java -Dpairgoth.mode=client -jar application/target/pairgoth-engine.jar +# mvn -DskipTests package && java -Dpairgoth.mode=client -jar application/target/pairgoth-engine.jar 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 index 44b907c..513b81a 100644 --- a/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/web/Shared.kt +++ b/pairgoth-common/src/main/kotlin/org/jeudego/pairgoth/web/Shared.kt @@ -16,3 +16,5 @@ val sharedSecret: String by lazy { if (it.length != 16) throw RuntimeException("shared secret must be 16 ascii chars long") } } + +fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } diff --git a/server.sh b/server.sh index 3088fa4..6aaa064 100755 --- a/server.sh +++ b/server.sh @@ -1,6 +1,6 @@ #!/bin/sh # debug version -# mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar +mvn -DskipTests package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -Dpairgoth.mode=server -jar application/target/pairgoth-engine.jar -mvn -DskipTests=true package && java -Dpairgoth.mode=server -jar application/target/pairgoth-engine.jar +# mvn -DskipTests=true package && java -Dpairgoth.mode=server -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 5b28182..5e007c5 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 @@ -40,8 +40,9 @@ abstract class OAuthHelper { fun getAccessToken(sessionID: String, code: String): String { val (url, params) = getAccessTokenURL(code) val json = JsonApiClient.post(url, null, *params.toTypedArray()).asObject() - val state = json.getString("state") ?: throw IOException("could not get state") - if (!checkState(state, sessionID)) throw IOException("invalid state") + // CB TODO - do not check state for now + // val state = json.getString("state") ?: throw IOException("could not get state") + // if (!checkState(state, sessionID)) throw IOException("invalid state") return json.getString("access_token") ?: throw IOException("could not get access token") } 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 7bcf508..793d767 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 @@ -10,6 +10,7 @@ import org.jeudego.pairgoth.web.AuthFilter import org.jeudego.pairgoth.web.WebappManager import org.slf4j.LoggerFactory import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse class ApiTool { companion object { @@ -36,7 +37,22 @@ class ApiTool { private fun Json.toRequestBody() = toString().toRequestBody(JSON.toMediaType()) private fun Request.Builder.process(): Json { try { - return client.newCall(build()).execute().use { response -> + val apiReq = build() + if (logger.isTraceEnabled) { + logger.trace(">> ${apiReq.method} ${apiReq.url}") + apiReq.headers.forEach { header -> + logger.trace(" ${header.first} ${header.second}") + } + } + + logger.trace(" ") + return client.newCall(apiReq).execute().use { response -> + if (logger.isTraceEnabled) { + logger.trace("<< ${response.code} ${response.message}") + response.headers.forEach { header -> + logger.trace(" ${header.first} ${header.second}") + } + } if (response.isSuccessful) { when (response.body?.contentType()?.subtype) { null -> throw Error("null body or content type") @@ -44,6 +60,10 @@ class ApiTool { else -> throw Error("unhandled content type: ${response.body!!.contentType()}") } } else { + if (response.code == HttpServletResponse.SC_UNAUTHORIZED) { + request.session.removeAttribute(AuthFilter.SESSION_KEY_API_TOKEN) + request.session.removeAttribute(AuthFilter.SESSION_KEY_USER) + } when (response.body?.contentType()?.subtype) { "json" -> Json.parse(response.body!!.string()) ?: throw Error("could not parse error json") else -> throw Error("${response.code} ${response.message}") 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 54cac0e..befb42c 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 @@ -9,6 +9,7 @@ import org.jeudego.pairgoth.oauth.OauthHelperFactory import org.jeudego.pairgoth.util.AESCryptograph import org.jeudego.pairgoth.view.ApiTool import org.slf4j.LoggerFactory +import java.io.IOException import java.nio.charset.StandardCharsets import java.security.MessageDigest import javax.servlet.Filter @@ -95,39 +96,55 @@ class AuthFilter: Filter { } 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 + try { + logger.trace("getting challenge...") + val challengeReq = Request.Builder().url("${ApiTool.apiRoot}tour/token") + .header("Accept", "application/json") + .header("Authorization", "Bearer ${getBearer(req)}") + .build() + val challengeResp = client.newCall(challengeReq).execute() + challengeResp.use { + if (challengeResp.code == HttpServletResponse.SC_UNAUTHORIZED) { + logger.trace("building answer...") + 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) + ).toHex() + val answer = Json.Object( + "session" to req.session.id, + "email" to email, + "signature" to signature + ) + val answerReq = Request.Builder().url("${ApiTool.apiRoot}tour/token") + .header("Accept", "application/json") + .post(answer.toString().toRequestBody(ApiTool.JSON.toMediaType())) + .build() + val answerResp = client.newCall(answerReq).execute() + answerResp.use { + 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) { + logger.trace("got token $token") + return token + } + } + } + } } } } + } catch (e: IOException) { + logger.warn("could not fetch access token", e) } return null } diff --git a/view-webapp/src/main/webapp/WEB-INF/logger.properties b/view-webapp/src/main/webapp/WEB-INF/logger.properties deleted file mode 100644 index 3b8f513..0000000 --- a/view-webapp/src/main/webapp/WEB-INF/logger.properties +++ /dev/null @@ -1 +0,0 @@ -level = info diff --git a/webserver/src/main/kotlin/org/jeudego/pairgoth/application/Pairgoth.kt b/webserver/src/main/kotlin/org/jeudego/pairgoth/application/Pairgoth.kt index cf6ce49..c2c8cbe 100644 --- a/webserver/src/main/kotlin/org/jeudego/pairgoth/application/Pairgoth.kt +++ b/webserver/src/main/kotlin/org/jeudego/pairgoth/application/Pairgoth.kt @@ -59,17 +59,44 @@ private fun cleanup() { FileUtils.deleteDirectory(webapps.toFile()) } +private val allowedModes = setOf("standalone", "server", "client") + private fun readProperties() { - val defaultProps = getResource("/server.default.properties") ?: throw Error("missing default server properties") + // do a first pass at determining the final 'mode', since it will influence other default value + var mode = "standalone" + val userProperties = File("./pairgoth.properties") + if (userProperties.exists()) { + val userProps = Properties() + userProps.load(FileReader(userProperties)) + if (userProps.contains("mode")) mode = userProps.getProperty("mode") + } + val systemMode: String? = System.getProperty("pairgoth.mode") + if (systemMode != null) { + mode = systemMode + } + + if (!allowedModes.contains(mode)) throw Error("invalid mode: $mode") + + // read default properties + val defaultProps = getResource("/${mode}.default.properties") ?: throw Error("missing default server properties") defaultProps.openStream().use { serverProps.load(InputStreamReader(it, StandardCharsets.UTF_8)) } - val properties = File("./pairgoth.properties") - if (properties.exists()) { - serverProps.load(FileReader(properties)) - } + // default env depends upon the presence of the pom.xml file val env = if (File("./pom.xml").exists()) "dev" else "prod" serverProps["env"] = env + // read user properties + if (userProperties.exists()) { + serverProps.load(FileReader(userProperties)) + } + // read system properties + System.getProperties().forEach { + val key = it.key as String + val value = it.value as String + if (key.startsWith("pairgoth.")) { + serverProps[key.removePrefix("pairgoth.")] = value + } + } } private fun publishProperties() { @@ -149,12 +176,23 @@ private fun launchServer() { } } - val webappUrl = URL( - serverProps.getProperty("webapp.protocol") ?: throw Error("missing property webapp.protocol"), - serverProps.getProperty("webapp.protocol") ?: throw Error("missing property webapp.protocol"), - serverProps.getProperty("webapp.port")?.toInt() ?: 80, - "/" - ) + val webappUrl = when (mode) { + "client", "standalone" -> + URL( + serverProps.getProperty("webapp.protocol") ?: throw Error("missing property webapp.protocol"), + serverProps.getProperty("webapp.host") ?: throw Error("missing property webapp.host"), + serverProps.getProperty("webapp.port")?.toInt() ?: 80, + "/" + ) + "server" -> + URL( + serverProps.getProperty("api.protocol") ?: throw Error("missing property api.protocol"), + serverProps.getProperty("api.host") ?: throw Error("missing property api.host"), + serverProps.getProperty("api.port")?.toInt() ?: 80, + "/" + ) + else -> throw Error("invalid mode: $mode") + } val secure = webappUrl.protocol == "https" // create server diff --git a/webserver/src/main/resources/client.default.properties b/webserver/src/main/resources/client.default.properties new file mode 100644 index 0000000..58a4438 --- /dev/null +++ b/webserver/src/main/resources/client.default.properties @@ -0,0 +1,18 @@ +mode = standalone +# webapp connector +webapp.protocol = http +webapp.host = localhost +webapp.port = 8080 +webapp.context = / +webapp.external.url = http://localhost:8080 + +# api connector +api.protocol = http +api.host = localhost +api.port = 8085 +api.context = /api/tour +api.external.url = http://localhost:8085/api/ + +webapp.ssl.key = jar:file:$jar!/ssl/localhost.key +webapp.ssl.pass = +webapp.ssl.cert = jar:file:$jar!/ssl/localhost.crt diff --git a/webserver/src/main/resources/server.default.properties b/webserver/src/main/resources/server.default.properties index 92b9edd..4cb6a3a 100644 --- a/webserver/src/main/resources/server.default.properties +++ b/webserver/src/main/resources/server.default.properties @@ -1,17 +1,17 @@ -mode = standalone +mode = server # webapp connector webapp.protocol = http -webapp.interface = localhost +webapp.host = localhost webapp.port = 8080 webapp.context = / webapp.external.url = http://localhost:8080 # api connector api.protocol = http -api.interface = localhost -api.port = 8080 +api.host = localhost +api.port = 8085 api.context = /api/tour -api.external.url = http://localhost:8080/api/ +api.external.url = http://localhost:8085/api/ webapp.ssl.key = jar:file:$jar!/ssl/localhost.key webapp.ssl.pass = diff --git a/webserver/src/main/resources/standalone.default.properties b/webserver/src/main/resources/standalone.default.properties new file mode 100644 index 0000000..a114563 --- /dev/null +++ b/webserver/src/main/resources/standalone.default.properties @@ -0,0 +1,18 @@ +mode = standalone +# webapp connector +webapp.protocol = http +webapp.host = localhost +webapp.port = 8080 +webapp.context = / +webapp.external.url = http://localhost:8080 + +# api connector +api.protocol = http +api.host = localhost +api.port = 8080 +api.context = /api/tour +api.external.url = http://localhost:8080/api/ + +webapp.ssl.key = jar:file:$jar!/ssl/localhost.key +webapp.ssl.pass = +webapp.ssl.cert = jar:file:$jar!/ssl/localhost.crt