This commit is contained in:
Claude Brisson
2024-02-26 15:09:48 +01:00
parent 71549f185e
commit 924b31d24b
13 changed files with 191 additions and 68 deletions

View File

@@ -8,6 +8,9 @@ import org.jeudego.pairgoth.util.AESCryptograph
import org.jeudego.pairgoth.util.Cryptograph import org.jeudego.pairgoth.util.Cryptograph
import org.jeudego.pairgoth.util.Randomizer import org.jeudego.pairgoth.util.Randomizer
import org.jeudego.pairgoth.web.sharedSecret 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.Random
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
@@ -18,6 +21,7 @@ object TokenHandler: ApiHandler {
const val AUTH_HEADER = "Authorization" const val AUTH_HEADER = "Authorization"
const val AUTH_PREFIX = "Bearer" const val AUTH_PREFIX = "Bearer"
private val hasher = MessageDigest.getInstance("SHA-256")
private val cryptograph = AESCryptograph().apply { init(sharedSecret) } private val cryptograph = AESCryptograph().apply { init(sharedSecret) }
private data class AuthorizationPayload( private data class AuthorizationPayload(
@@ -26,21 +30,26 @@ object TokenHandler: ApiHandler {
val userInfos: Json val userInfos: Json
) )
private fun getAuthorizationPayload(request: HttpServletRequest): AuthorizationPayload? { private fun parseAuthorizationHeader(request: HttpServletRequest): Pair<String, String>? {
val authorize = request.getHeader(AUTH_HEADER) as String? val authorize = request.getHeader(AUTH_HEADER) as String?
if (authorize != null && authorize.startsWith("$AUTH_PREFIX ")) { if (authorize != null && authorize.startsWith("$AUTH_PREFIX ")) {
val bearer = authorize.substring(AUTH_PREFIX.length + 1) val bearer = authorize.substring(AUTH_PREFIX.length + 1)
val clear = cryptograph.webDecrypt(bearer) val clear = cryptograph.webDecrypt(bearer)
val parts = clear.split(':') val parts = clear.split(':')
if (parts.size == 2) { if (parts.size == 2) {
val sessionId = parts[0] return Pair(parts[0], parts[1])
val accessKey = parts[1] }
}
return null
}
private fun getAuthorizationPayload(request: HttpServletRequest): AuthorizationPayload? {
parseAuthorizationHeader(request)?.let { (sessionId, accessKey) ->
val accessPayload = accesses.getIfPresent(accessKey) val accessPayload = accesses.getIfPresent(accessKey)
if (accessPayload != null && sessionId == accessPayload.getString("session")) { if (accessPayload != null && sessionId == accessPayload.getString("session")) {
return AuthorizationPayload(sessionId, accessKey, accessPayload) return AuthorizationPayload(sessionId, accessKey, accessPayload)
} }
} }
}
return null return null
} }
@@ -63,15 +72,15 @@ object TokenHandler: ApiHandler {
if (challenge != null) { if (challenge != null) {
val email = auth.getString("email") val email = auth.getString("email")
val signature = auth.getString("signature") val signature = auth.getString("signature")
val expectedSignature = cryptograph.webEncrypt( val expectedSignature = hasher.digest(
"${ "${
session session
}:${ }:${
challenge challenge
}:${ }:${
email email
}" }".toByteArray(StandardCharsets.UTF_8)
) ).toHex()
if (signature == expectedSignature) { if (signature == expectedSignature) {
val accessKey = Randomizer.randomString(32) val accessKey = Randomizer.randomString(32)
accesses.put(accessKey, Json.Object( accesses.put(accessKey, Json.Object(
@@ -93,10 +102,11 @@ object TokenHandler: ApiHandler {
} }
private fun failed(request: HttpServletRequest, response: HttpServletResponse) { private fun failed(request: HttpServletRequest, response: HttpServletResponse) {
val authPayload = getAuthorizationPayload(request) val authValues = parseAuthorizationHeader(request)
if (authPayload != null && authPayload.sessionId.isNotEmpty()) { if (authValues != null && authValues.first.isNotEmpty()) {
val sessionId = authValues.first
val challenge = Randomizer.randomString(32) val challenge = Randomizer.randomString(32)
challenges.put(authPayload.sessionId, challenge) challenges.put(sessionId, challenge)
response.addHeader("WWW-Authenticate", challenge) response.addHeader("WWW-Authenticate", challenge)
response.status = HttpServletResponse.SC_UNAUTHORIZED response.status = HttpServletResponse.SC_UNAUTHORIZED
response.writer.println(Json.Object("status" to "failed", "message" to "unauthorized")) response.writer.println(Json.Object("status" to "failed", "message" to "unauthorized"))

View File

@@ -56,6 +56,7 @@ class ApiServlet: HttpServlet() {
val requestLock = if (request.method == "GET") lock.readLock() else lock.writeLock() val requestLock = if (request.method == "GET") lock.readLock() else lock.writeLock()
try { try {
requestLock.lock() requestLock.lock()
logger.logRequest(request, !request.requestURI.contains(".") && request.requestURI.length > 1)
if (checkAuthorization(request, response)) { if (checkAuthorization(request, response)) {
doProtectedRequest(request, response) doProtectedRequest(request, response)
} else { } else {
@@ -68,7 +69,6 @@ class ApiServlet: HttpServlet() {
private fun doProtectedRequest(request: HttpServletRequest, response: HttpServletResponse) { private fun doProtectedRequest(request: HttpServletRequest, response: HttpServletResponse) {
val uri = request.requestURI val uri = request.requestURI
logger.logRequest(request, !uri.contains(".") && uri.length > 1)
var payload: Json? = null var payload: Json? = null
var reason = "OK" var reason = "OK"

View File

@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# debug version # 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

View File

@@ -16,3 +16,5 @@ val sharedSecret: String by lazy {
if (it.length != 16) throw RuntimeException("shared secret must be 16 ascii chars long") if (it.length != 16) throw RuntimeException("shared secret must be 16 ascii chars long")
} }
} }
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }

View File

@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# debug version # 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

View File

@@ -40,8 +40,9 @@ abstract class OAuthHelper {
fun getAccessToken(sessionID: String, code: String): String { fun getAccessToken(sessionID: String, code: String): String {
val (url, params) = getAccessTokenURL(code) val (url, params) = getAccessTokenURL(code)
val json = JsonApiClient.post(url, null, *params.toTypedArray()).asObject() val json = JsonApiClient.post(url, null, *params.toTypedArray()).asObject()
val state = json.getString("state") ?: throw IOException("could not get state") // CB TODO - do not check state for now
if (!checkState(state, sessionID)) throw IOException("invalid state") // 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") return json.getString("access_token") ?: throw IOException("could not get access token")
} }

View File

@@ -10,6 +10,7 @@ import org.jeudego.pairgoth.web.AuthFilter
import org.jeudego.pairgoth.web.WebappManager import org.jeudego.pairgoth.web.WebappManager
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
class ApiTool { class ApiTool {
companion object { companion object {
@@ -36,7 +37,22 @@ class ApiTool {
private fun Json.toRequestBody() = toString().toRequestBody(JSON.toMediaType()) private fun Json.toRequestBody() = toString().toRequestBody(JSON.toMediaType())
private fun Request.Builder.process(): Json { private fun Request.Builder.process(): Json {
try { 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) { if (response.isSuccessful) {
when (response.body?.contentType()?.subtype) { when (response.body?.contentType()?.subtype) {
null -> throw Error("null body or content type") null -> throw Error("null body or content type")
@@ -44,6 +60,10 @@ class ApiTool {
else -> throw Error("unhandled content type: ${response.body!!.contentType()}") else -> throw Error("unhandled content type: ${response.body!!.contentType()}")
} }
} else { } 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) { when (response.body?.contentType()?.subtype) {
"json" -> Json.parse(response.body!!.string()) ?: throw Error("could not parse error json") "json" -> Json.parse(response.body!!.string()) ?: throw Error("could not parse error json")
else -> throw Error("${response.code} ${response.message}") else -> throw Error("${response.code} ${response.message}")

View File

@@ -9,6 +9,7 @@ import org.jeudego.pairgoth.oauth.OauthHelperFactory
import org.jeudego.pairgoth.util.AESCryptograph import org.jeudego.pairgoth.util.AESCryptograph
import org.jeudego.pairgoth.view.ApiTool import org.jeudego.pairgoth.view.ApiTool
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.MessageDigest import java.security.MessageDigest
import javax.servlet.Filter import javax.servlet.Filter
@@ -95,11 +96,16 @@ class AuthFilter: Filter {
} }
fun fetchApiToken(req: HttpServletRequest, user: Json.Object): String? { fun fetchApiToken(req: HttpServletRequest, user: Json.Object): String? {
try {
logger.trace("getting challenge...")
val challengeReq = Request.Builder().url("${ApiTool.apiRoot}tour/token") val challengeReq = Request.Builder().url("${ApiTool.apiRoot}tour/token")
.header("Accept", "application/json")
.header("Authorization", "Bearer ${getBearer(req)}") .header("Authorization", "Bearer ${getBearer(req)}")
.build() .build()
val challengeResp = client.newCall(challengeReq).execute() val challengeResp = client.newCall(challengeReq).execute()
challengeResp.use {
if (challengeResp.code == HttpServletResponse.SC_UNAUTHORIZED) { if (challengeResp.code == HttpServletResponse.SC_UNAUTHORIZED) {
logger.trace("building answer...")
val email = user.getString("email") ?: "-" val email = user.getString("email") ?: "-"
val challenge = challengeResp.headers["WWW-Authenticate"] val challenge = challengeResp.headers["WWW-Authenticate"]
if (challenge != null) { if (challenge != null) {
@@ -110,25 +116,36 @@ class AuthFilter: Filter {
challenge challenge
}:${ }:${
email email
}".toByteArray(StandardCharsets.UTF_8)) }".toByteArray(StandardCharsets.UTF_8)
).toHex()
val answer = Json.Object( val answer = Json.Object(
"session" to req.session.id, "session" to req.session.id,
"email" to email, "email" to email,
"signature" to signature "signature" to signature
) )
val answerReq = Request.Builder().url("${ApiTool.apiRoot}tour/token").post( val answerReq = Request.Builder().url("${ApiTool.apiRoot}tour/token")
answer.toString().toRequestBody(ApiTool.JSON.toMediaType()) .header("Accept", "application/json")
).build() .post(answer.toString().toRequestBody(ApiTool.JSON.toMediaType()))
.build()
val answerResp = client.newCall(answerReq).execute() val answerResp = client.newCall(answerReq).execute()
answerResp.use {
if (answerResp.isSuccessful && "json" == answerResp.body?.contentType()?.subtype) { if (answerResp.isSuccessful && "json" == answerResp.body?.contentType()?.subtype) {
val payload = Json.parse(answerResp.body!!.string()) val payload = Json.parse(answerResp.body!!.string())
if (payload != null && payload.isObject) { if (payload != null && payload.isObject) {
val token = payload.asObject().getString("token") val token = payload.asObject().getString("token")
if (token != null) return token if (token != null) {
logger.trace("got token $token")
return token
} }
} }
} }
} }
}
}
}
} catch (e: IOException) {
logger.warn("could not fetch access token", e)
}
return null return null
} }

View File

@@ -1 +0,0 @@
level = info

View File

@@ -59,17 +59,44 @@ private fun cleanup() {
FileUtils.deleteDirectory(webapps.toFile()) FileUtils.deleteDirectory(webapps.toFile())
} }
private val allowedModes = setOf("standalone", "server", "client")
private fun readProperties() { 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 { defaultProps.openStream().use {
serverProps.load(InputStreamReader(it, StandardCharsets.UTF_8)) serverProps.load(InputStreamReader(it, StandardCharsets.UTF_8))
} }
val properties = File("./pairgoth.properties") // default env depends upon the presence of the pom.xml file
if (properties.exists()) {
serverProps.load(FileReader(properties))
}
val env = if (File("./pom.xml").exists()) "dev" else "prod" val env = if (File("./pom.xml").exists()) "dev" else "prod"
serverProps["env"] = env 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() { private fun publishProperties() {
@@ -149,12 +176,23 @@ private fun launchServer() {
} }
} }
val webappUrl = URL( val webappUrl = when (mode) {
serverProps.getProperty("webapp.protocol") ?: throw Error("missing property webapp.protocol"), "client", "standalone" ->
URL(
serverProps.getProperty("webapp.protocol") ?: throw Error("missing property webapp.protocol"), 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, 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" val secure = webappUrl.protocol == "https"
// create server // create server

View File

@@ -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

View File

@@ -1,17 +1,17 @@
mode = standalone mode = server
# webapp connector # webapp connector
webapp.protocol = http webapp.protocol = http
webapp.interface = localhost webapp.host = localhost
webapp.port = 8080 webapp.port = 8080
webapp.context = / webapp.context = /
webapp.external.url = http://localhost:8080 webapp.external.url = http://localhost:8080
# api connector # api connector
api.protocol = http api.protocol = http
api.interface = localhost api.host = localhost
api.port = 8080 api.port = 8085
api.context = /api/tour 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.key = jar:file:$jar!/ssl/localhost.key
webapp.ssl.pass = webapp.ssl.pass =

View File

@@ -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