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.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,21 +30,26 @@ object TokenHandler: ApiHandler {
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?
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]
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"))

View File

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

View File

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

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")
}
}
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }

View File

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

View File

@@ -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")
}

View File

@@ -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}")

View File

@@ -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,11 +96,16 @@ class AuthFilter: Filter {
}
fun fetchApiToken(req: HttpServletRequest, user: Json.Object): String? {
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) {
@@ -110,25 +116,36 @@ class AuthFilter: Filter {
challenge
}:${
email
}".toByteArray(StandardCharsets.UTF_8))
}".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").post(
answer.toString().toRequestBody(ApiTool.JSON.toMediaType())
).build()
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) 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
}

View File

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

View File

@@ -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"),
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

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.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 =

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