Auth still in progress

This commit is contained in:
Claude Brisson
2024-02-26 09:54:28 +01:00
parent 69d4a9c1e6
commit 71549f185e
27 changed files with 295 additions and 84 deletions

View File

@@ -65,6 +65,7 @@
<port>${pairgoth.api.port}</port> <port>${pairgoth.api.port}</port>
</httpConnector> </httpConnector>
<systemProperties> <systemProperties>
<pairgoth.auth>${pairgoth.auth}</pairgoth.auth>
<pairgoth.env>${pairgoth.env}</pairgoth.env> <pairgoth.env>${pairgoth.env}</pairgoth.env>
<pairgoth.version>${pairgoth.version}</pairgoth.version> <pairgoth.version>${pairgoth.version}</pairgoth.version>
<pairgoth.api.external.url>${pairgoth.api.external.url}</pairgoth.api.external.url> <pairgoth.api.external.url>${pairgoth.api.external.url}</pairgoth.api.external.url>
@@ -164,6 +165,11 @@
<version>2.13.0</version> <version>2.13.0</version>
</dependency> </dependency>
<!-- auth --> <!-- auth -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<dependency> <dependency>
<groupId>commons-codec</groupId> <groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId> <artifactId>commons-codec</artifactId>

View File

@@ -21,7 +21,7 @@ interface ApiHandler {
notImplemented() notImplemented()
} }
fun post(request: HttpServletRequest, response: HttpServletResponse): Json { fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
notImplemented() notImplemented()
} }

View File

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

View File

@@ -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 tournament = getTournament(request)
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number") val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played") if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played")

View File

@@ -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 tournament = getTournament(request)
val payload = getObjectPayload(request) val payload = getObjectPayload(request)
val player = Player.fromJson(payload) val player = Player.fromJson(payload)

View File

@@ -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) val tournament = getTournament(request)
if (tournament !is TeamTournament) badRequest("tournament is not a team tournament") if (tournament !is TeamTournament) badRequest("tournament is not a team tournament")
val payload = getObjectPayload(request) val payload = getObjectPayload(request)

View File

@@ -1,55 +1,120 @@
package org.jeudego.pairgoth.api 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 com.republicate.kson.Json
import org.jeudego.pairgoth.server.ApiServlet
import org.jeudego.pairgoth.util.AESCryptograph 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.web.sharedSecret
import java.util.Random
import java.util.concurrent.TimeUnit
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse import javax.servlet.http.HttpServletResponse
import javax.servlet.http.HttpSession
class TokenHandler: ApiHandler { object TokenHandler: ApiHandler {
companion object {
const val AUTH_KEY = "pairgoth-auth" const val AUTH_HEADER = "Authorization"
const val CHALLENGE_KEY = "pairgoth-challenge" const val AUTH_PREFIX = "Bearer"
private val cryptograph: Cryptograph = AESCryptograph().apply {
init("78659783ed8ccc0e") 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? { override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val auth = request.session.getAttribute(AUTH_KEY) as String? if (getLoggedUser(request) == null) {
if (auth == null) {
failed(request, response) failed(request, response)
return null return null
} else { } else {
return Json.Object( return Json.Object("success" to true)
"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)
} }
} }
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 { 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) return Json.Object("success" to true)
} }
private fun failed(request: HttpServletRequest, response: HttpServletResponse) { private fun failed(request: HttpServletRequest, response: HttpServletResponse) {
val session = request.session val authPayload = getAuthorizationPayload(request)
val challenge = AuthChallenge() if (authPayload != null && authPayload.sessionId.isNotEmpty()) {
session.setAttribute(CHALLENGE_KEY, challenge) val challenge = Randomizer.randomString(32)
response.addHeader("WWW-Authenticate", challenge.value) challenges.put(authPayload.sessionId, 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"))
} else {
response.status = HttpServletResponse.SC_BAD_REQUEST
} }
}
// a short-lived cache for sessionid <--> challenge association
private val challenges: Cache<String, String> = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.maximumSize(100)
.build()
// a long-lived cache for access key <--> user association
private val accesses: Cache<String, Json.Object> = Caffeine.newBuilder()
.expireAfterWrite(30, TimeUnit.DAYS)
.maximumSize(100)
.build()
} }

View File

@@ -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)) { val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
is Json.Object -> Tournament.fromJson(getObjectPayload(request)) is Json.Object -> Tournament.fromJson(getObjectPayload(request))
is Element -> OpenGotha.import(payload) is Element -> OpenGotha.import(payload)

View File

@@ -1,5 +1,7 @@
package org.jeudego.pairgoth.server 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 com.republicate.kson.Json
import org.apache.commons.io.input.BOMInputStream import org.apache.commons.io.input.BOMInputStream
import org.jeudego.pairgoth.api.ApiHandler 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.ResultsHandler
import org.jeudego.pairgoth.api.StandingsHandler import org.jeudego.pairgoth.api.StandingsHandler
import org.jeudego.pairgoth.api.TeamHandler import org.jeudego.pairgoth.api.TeamHandler
import org.jeudego.pairgoth.api.TokenHandler
import org.jeudego.pairgoth.api.TournamentHandler 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.blue
import org.jeudego.pairgoth.util.Colorizer.green import org.jeudego.pairgoth.util.Colorizer.green
import org.jeudego.pairgoth.util.Colorizer.red import org.jeudego.pairgoth.util.Colorizer.red
import org.jeudego.pairgoth.util.XmlUtils import org.jeudego.pairgoth.util.XmlUtils
import org.jeudego.pairgoth.util.parse import org.jeudego.pairgoth.util.parse
import org.jeudego.pairgoth.util.toString import org.jeudego.pairgoth.util.toString
import org.jeudego.pairgoth.web.sharedSecret
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.w3c.dom.Element import org.w3c.dom.Element
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader import java.io.InputStreamReader
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReadWriteLock import java.util.concurrent.locks.ReadWriteLock
import java.util.concurrent.locks.ReentrantReadWriteLock import java.util.concurrent.locks.ReentrantReadWriteLock
import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse import javax.servlet.http.HttpServletResponse
class ApiServlet: HttpServlet() { class ApiServlet: HttpServlet() {
public override fun doGet(request: HttpServletRequest, response: HttpServletResponse) { 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() val requestLock = if (request.method == "GET") lock.readLock() else lock.writeLock()
try { try {
requestLock.lock() requestLock.lock()
if (checkAuthorization(request, response)) {
doProtectedRequest(request, response) doProtectedRequest(request, response)
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
}
} finally { } finally {
requestLock.unlock() requestLock.unlock()
} }
@@ -85,7 +96,8 @@ class ApiServlet: HttpServlet() {
val handler = when (entity) { val handler = when (entity) {
"tour" -> "tour" ->
when (subEntity) { if ("token" == selector) TokenHandler
else when (subEntity) {
null -> TournamentHandler null -> TournamentHandler
"part" -> PlayerHandler "part" -> PlayerHandler
"pair" -> PairingHandler "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 { companion object {
private var logger = LoggerFactory.getLogger("api") private var logger = LoggerFactory.getLogger("api")
private const val EXPECTED_CHARSET = "utf8" private const val EXPECTED_CHARSET = "utf8"
const val AUTH_HEADER = "Authorization" const val USER_KEY = "pairgoth-user"
const val AUTH_PREFIX = "Bearer"
fun isJson(mimeType: String) = "text/json" == mimeType || "application/json" == mimeType || mimeType.endsWith("+json") 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") fun isXml(mimeType: String) = "text/xml" == mimeType || "application/xml" == mimeType || mimeType.endsWith("+xml")
} }

View File

@@ -1,10 +1,6 @@
#!/bin/bash #!/bin/sh
trap 'kill $CSSWATCH; exit' INT # debug version
( cd view-webapp; ./csswatch.sh ) & # mvn package && java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:5006 -jar application/target/pairgoth-engine.jar
CSSWATCH=$!
export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5006" mvn -DskipTests=true package && java -Dpairgoth.mode=client -jar application/target/pairgoth-engine.jar
#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

10
debug-client.sh Executable file
View File

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

4
debug-server.sh Executable file
View File

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

View File

@@ -52,6 +52,5 @@ class AESCryptograph : Cryptograph {
companion object { companion object {
private val CIPHER = "AES/ECB/PKCS5Padding" private val CIPHER = "AES/ECB/PKCS5Padding"
private val ALGORITHM = "AES" private val ALGORITHM = "AES"
} }
} }

View File

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

View File

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

View File

@@ -71,17 +71,17 @@
<pairgoth.webapp.host>localhost</pairgoth.webapp.host> <pairgoth.webapp.host>localhost</pairgoth.webapp.host>
<pairgoth.webapp.port>8080</pairgoth.webapp.port> <pairgoth.webapp.port>8080</pairgoth.webapp.port>
<pairgoth.webapp.context>/</pairgoth.webapp.context> <pairgoth.webapp.context>/</pairgoth.webapp.context>
<!-- CB TODO - what if webapp context does not qtart with '/' ? -->
<pairgoth.webapp.external.url>${pairgoth.webapp.protocol}://${pairgoth.webapp.host}:${pairgoth.webapp.port}${pairgoth.webapp.context}</pairgoth.webapp.external.url> <pairgoth.webapp.external.url>${pairgoth.webapp.protocol}://${pairgoth.webapp.host}:${pairgoth.webapp.port}${pairgoth.webapp.context}</pairgoth.webapp.external.url>
<pairgoth.api.protocol>http</pairgoth.api.protocol> <pairgoth.api.protocol>http</pairgoth.api.protocol>
<pairgoth.api.host>localhost</pairgoth.api.host> <pairgoth.api.host>localhost</pairgoth.api.host>
<pairgoth.api.port>8085</pairgoth.api.port> <pairgoth.api.port>8085</pairgoth.api.port>
<pairgoth.api.context>/api</pairgoth.api.context> <pairgoth.api.context>/api</pairgoth.api.context>
<pairgoth.api.external.url>${pairgoth.api.protocol}://${pairgoth.api.host}:${pairgoth.api.port}${pairgoth.api.context}</pairgoth.api.external.url> <pairgoth.api.external.url>${pairgoth.api.protocol}://${pairgoth.api.host}:${pairgoth.api.port}${pairgoth.api.context}</pairgoth.api.external.url>
<pairgoth.mode>standalone</pairgoth.mode>
<pairgoth.store>file</pairgoth.store> <pairgoth.store>file</pairgoth.store>
<pairgoth.store.file.path>tournamentfiles</pairgoth.store.file.path> <pairgoth.store.file.path>tournamentfiles</pairgoth.store.file.path>
<pairgoth.auth>none</pairgoth.auth> <pairgoth.auth>none</pairgoth.auth>
<pairgoth.auth.sesame>this_should_be_overriden_with_a_command_line_option</pairgoth.auth.sesame> <pairgoth.auth.sesame>this_should_be_overriden</pairgoth.auth.sesame>
<pairgoth.oauth.ffg.client_id>pairtogh</pairgoth.oauth.ffg.client_id> <pairgoth.oauth.ffg.client_id>pairtogh</pairgoth.oauth.ffg.client_id>
<pairgoth.smtp.sender></pairgoth.smtp.sender> <pairgoth.smtp.sender></pairgoth.smtp.sender>
<pairgoth.smtp.host></pairgoth.smtp.host> <pairgoth.smtp.host></pairgoth.smtp.host>

View File

@@ -1,4 +1,6 @@
#!/bin/bash #!/bin/sh
export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005" # debug version
mvn -DskipTests=true --projects api-webapp package jetty:run # 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

View File

@@ -3,4 +3,4 @@
# 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 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

View File

@@ -10,6 +10,7 @@ import org.apache.http.NameValuePair
import org.jeudego.pairgoth.util.AESCryptograph import org.jeudego.pairgoth.util.AESCryptograph
import org.jeudego.pairgoth.util.ApiClient.JsonApiClient import org.jeudego.pairgoth.util.ApiClient.JsonApiClient
import org.jeudego.pairgoth.util.Cryptograph import org.jeudego.pairgoth.util.Cryptograph
import org.jeudego.pairgoth.util.Randomizer
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException import java.io.IOException
@@ -55,7 +56,7 @@ abstract class OAuthHelper {
companion object { companion object {
protected var logger: Logger = LoggerFactory.getLogger("oauth") protected var logger: Logger = LoggerFactory.getLogger("oauth")
private val cryptograph: Cryptograph = AESCryptograph().apply { private val cryptograph: Cryptograph = AESCryptograph().apply {
init("0efd28fb53cbac42") init(Randomizer.randomString(16))
} }
} }
} }

View File

@@ -15,11 +15,11 @@ object CredentialsChecker {
val sha256 = hasher.digest(password.toByteArray(StandardCharsets.UTF_8)).toHexString() val sha256 = hasher.digest(password.toByteArray(StandardCharsets.UTF_8)).toHexString()
DriverManager.getConnection("jdbc:sqlite:$CREDENTIALS_DB").use { conn -> DriverManager.getConnection("jdbc:sqlite:$CREDENTIALS_DB").use { conn ->
val rs = 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(1, email)
setString(2, password) setString(2, password)
}.executeQuery() }.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() { fun initDatabase() {
if (!File(CREDENTIALS_DB).exists()) { if (!File(CREDENTIALS_DB).exists()) {
DriverManager.getConnection("jdbc:sqlite:$CREDENTIALS_DB").use { conn -> 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)")
} }
} }
} }

View File

@@ -6,8 +6,10 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.EMPTY_REQUEST import okhttp3.internal.EMPTY_REQUEST
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
class ApiTool { class ApiTool {
companion object { companion object {
@@ -19,8 +21,18 @@ class ApiTool {
} }
val logger = LoggerFactory.getLogger("api") 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 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 Json.toRequestBody() = toString().toRequestBody(JSON.toMediaType())
private fun Request.Builder.process(): Json { private fun Request.Builder.process(): Json {
try { try {

View File

@@ -2,12 +2,15 @@ package org.jeudego.pairgoth.web
import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.proxy.AsyncProxyServlet; import org.eclipse.jetty.proxy.AsyncProxyServlet;
import org.jeudego.pairgoth.view.ApiTool
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
class ApiServlet : AsyncProxyServlet() { class ApiServlet : AsyncProxyServlet() {
override fun addProxyHeaders(clientRequest: HttpServletRequest, proxyRequest: Request) { 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 { override fun rewriteTarget(clientRequest: HttpServletRequest): String {

View File

@@ -1,6 +1,16 @@
package org.jeudego.pairgoth.web 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.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.Filter
import javax.servlet.FilterChain import javax.servlet.FilterChain
import javax.servlet.FilterConfig import javax.servlet.FilterConfig
@@ -36,12 +46,13 @@ class AuthFilter: Filter {
val helper = OauthHelperFactory.getHelper(provider) val helper = OauthHelperFactory.getHelper(provider)
val accessToken = helper.getAccessToken(request.session.id, request.getParameter("code") ?: "") val accessToken = helper.getAccessToken(request.session.id, request.getParameter("code") ?: "")
val user = helper.getUserInfos(accessToken) val user = helper.getUserInfos(accessToken)
request.session.setAttribute("logged", user) handleSuccessfulLogin(req, user)
request.session.setAttribute(SESSION_KEY_USER, user)
response.sendRedirect("/index") response.sendRedirect("/index")
return 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) chain.doFilter(req, resp)
} else { } else {
// TODO - protection against brute force attacks // TODO - protection against brute force attacks
@@ -54,6 +65,14 @@ class AuthFilter: Filter {
} }
companion object { 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( private val whitelist = setOf(
"/login", "/login",
"/index-ffg", "/index-ffg",
@@ -66,5 +85,66 @@ class AuthFilter: Filter {
val nolangUri = uri.replace(Regex("^/../"), "/") val nolangUri = uri.replace(Regex("^/../"), "/")
return whitelist.contains(nolangUri) 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) ?: ""
}")
}
} }
} }

View File

@@ -20,7 +20,7 @@ class LoginServlet: HttpServlet() {
"sesame" -> checkSesame(payload) "sesame" -> checkSesame(payload)
else -> checkLoginPass(payload) else -> checkLoginPass(payload)
} ?: throw Error("authentication failed") } ?: throw Error("authentication failed")
req.session.setAttribute("logged", user) AuthFilter.handleSuccessfulLogin(req, user)
val ret = Json.Object("status" to "ok") val ret = Json.Object("status" to "ok")
resp.contentType = "application/json" resp.contentType = "application/json"
resp.writer.println(ret.toString()) resp.writer.println(ret.toString())
@@ -34,7 +34,7 @@ class LoginServlet: HttpServlet() {
fun checkSesame(payload: Json.Object): Json.Object? { fun checkSesame(payload: Json.Object): Json.Object? {
val expected = WebappManager.properties.getProperty("auth.sesame") ?: throw Error("sesame wrongly configured") 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? { fun checkLoginPass(payload: Json.Object): Json.Object? {

View File

@@ -1,6 +1,8 @@
package org.jeudego.pairgoth.web package org.jeudego.pairgoth.web
import com.republicate.kson.Json import com.republicate.kson.Json
import okhttp3.Request
import org.jeudego.pairgoth.view.ApiTool
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
@@ -9,7 +11,9 @@ import javax.servlet.http.HttpServletResponse
class LogoutServlet: HttpServlet() { class LogoutServlet: HttpServlet() {
override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { 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") val ret = Json.Object("status" to "ok")
resp.contentType = "application/json" resp.contentType = "application/json"
resp.writer.println(ret.toString()) resp.writer.println(ret.toString())

View File

@@ -19,9 +19,8 @@ let headers = function(withJson) {
if (withJson) { if (withJson) {
ret['Content-Type'] = 'application/json'; ret['Content-Type'] = 'application/json';
} }
let accessToken = store('accessToken'); if (apiToken) {
if (accessToken) { ret['Authorization'] = `Bearer ${apiToken}`;
ret['Authorization'] = `Bearer ${accessToken}`;
} }
return ret; return ret;
}; };

View File

@@ -45,6 +45,7 @@
<!-- error messages included as html elements so that they are translated --> <!-- error messages included as html elements so that they are translated -->
<div id="required_field" class="hidden">Required field</div> <div id="required_field" class="hidden">Required field</div>
<script type="text/javascript"> <script type="text/javascript">
const apiToken = '$!api.bearer';
#if($tour) #if($tour)
const tour_id = ${tour.id}; const tour_id = ${tour.id};
const tour_rounds = ${tour.rounds}; const tour_rounds = ${tour.rounds};