OAuth in progress

This commit is contained in:
Claude Brisson
2024-02-02 09:33:29 +01:00
parent d3fc71f72f
commit b431d7ab5c
10 changed files with 509 additions and 22 deletions

15
oauth.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/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 \
-Dpairgoth.auth=oauth \
-Dpairgoth.oauth.providers=ffg \
-Dpairgoth.oauth.ffg.secret=43f3a67bffcb5054d2f1b0e2a2374bdc \
-Dwebapp.external.url=http://localhost:8080
--projects view-webapp package jetty:run
kill $CSSWATCH

View File

@@ -80,7 +80,8 @@
<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></pairgoth.auth.sesame> <pairgoth.auth.sesame>this_should_be_overriden_with_a_command_line_option</pairgoth.auth.sesame>
<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>
<pairgoth.smtp.port>587</pairgoth.smtp.port> <pairgoth.smtp.port>587</pairgoth.smtp.port>

View File

@@ -232,9 +232,20 @@
--> -->
<!-- http client --> <!-- http client -->
<dependency> <dependency>
<groupId>com.squareup.okhttp3</groupId> <groupId>org.apache.httpcomponents</groupId>
<artifactId>okhttp</artifactId> <artifactId>httpclient</artifactId>
<version>4.8.1</version> <version>4.5.14</version>
<exclusions>
<exclusion>
<artifactId>commons-logging</artifactId>
<groupId>commons-logging</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
<version>4.5.14</version>
</dependency> </dependency>
<!-- server-side events --> <!-- server-side events -->
<dependency> <dependency>

View File

@@ -0,0 +1,29 @@
package org.jeudego.pairgoth.oauth
class FFGHelper : OAuthHelper() {
override val name: String
get() = "facebook"
private val FFG_HOST = "https://testffg"
override fun getLoginURL(sessionId: String?): String {
return "$FFG_HOST/oauth2/entry_point.php/authorize?" +
"client_id=" + clientId +
"&redirect_uri=" + redirectURI +
"&scope=email"
}
override fun getAccessTokenURL(code: String): String? {
return "$FFG_HOST/oauth2/entry_point.php/access_token?" +
"client_id=" + clientId +
"&redirect_uri=" + redirectURI +
"&client_secret=" + secret +
"&code=" + code
}
override fun getUserInfosURL(accessToken: String): String? {
return "$FFG_HOST/oauth2/entry_point.php/user_info?" +
"field=email" +
"&access_token=" + accessToken
}
}

View File

@@ -7,6 +7,10 @@ import org.jeudego.pairgoth.web.WebappManager
//import com.republicate.modality.util.AESCryptograph //import com.republicate.modality.util.AESCryptograph
//import com.republicate.modality.util.Cryptograph //import com.republicate.modality.util.Cryptograph
import org.apache.commons.codec.binary.Base64 import org.apache.commons.codec.binary.Base64
import org.jeudego.pairgoth.util.AESCryptograph
import org.jeudego.pairgoth.util.ApiClient.JsonApiClient
import org.jeudego.pairgoth.util.Cryptograph
import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException import java.io.IOException
import java.io.UnsupportedEncodingException import java.io.UnsupportedEncodingException
@@ -21,7 +25,7 @@ abstract class OAuthHelper {
protected get() = WebappManager.getMandatoryProperty("oauth." + name + ".secret") protected get() = WebappManager.getMandatoryProperty("oauth." + name + ".secret")
protected val redirectURI: String? protected val redirectURI: String?
protected get() = try { protected get() = try {
val uri: String = WebappManager.Companion.getProperty("webapp.external.url") + "/oauth.html" val uri: String = WebappManager.getMandatoryProperty("webapp.external.url") + "/oauth"
URLEncoder.encode(uri, "UTF-8") URLEncoder.encode(uri, "UTF-8")
} catch (uee: UnsupportedEncodingException) { } catch (uee: UnsupportedEncodingException) {
logger.error("could not encode redirect URI", uee) logger.error("could not encode redirect URI", uee)
@@ -48,30 +52,23 @@ abstract class OAuthHelper {
@Throws(IOException::class) @Throws(IOException::class)
fun getUserEmail(accessToken: String): String { fun getUserEmail(accessToken: String): String {
val json: Json.Object = Json.Object() val json = getUserInfosURL(accessToken)?.let { JsonApiClient.get(it).asObject() }
// TODO return json?.getString("email") ?: throw IOException("could not fetch email")
// apiClient.get(getUserInfosURL(accessToken))
return json.getString("email") ?: throw IOException("could not fetch email")
} }
companion object { companion object {
protected var logger = LoggerFactory.getLogger("oauth") protected var logger: Logger = LoggerFactory.getLogger("oauth")
private const val salt = "0efd28fb53cbac42" private const val salt = "0efd28fb53cbac42"
// private val sessionIdCrypto: Cryptograph = AESCryptograph().apply { private val sessionIdCrypto: Cryptograph = AESCryptograph().apply {
// init(salt) init(salt)
// } }
private fun encrypt(input: String): String { private fun encrypt(input: String): String {
return "TODO" return Base64.encodeBase64URLSafeString(sessionIdCrypto.encrypt(input))
// return Base64.encodeBase64URLSafeString(sessionIdCrypto.encrypt(input))
} }
private fun decrypt(input: String): String { private fun decrypt(input: String): String {
return "TODO" return sessionIdCrypto.decrypt(Base64.decodeBase64(input))
// return sessionIdCrypto.decrypt(Base64.decodeBase64(input))
} }
// TODO
// private val apiClient: ApiClient = ApiClient()
} }
} }

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.oauth package org.jeudego.pairgoth.oauth
object OauthHelperFactory { object OauthHelperFactory {
private val ffg: OAuthHelper = FFGHelper()
private val facebook: OAuthHelper = FacebookHelper() private val facebook: OAuthHelper = FacebookHelper()
private val google: OAuthHelper = GoogleHelper() private val google: OAuthHelper = GoogleHelper()
private val instagram: OAuthHelper = InstagramHelper() private val instagram: OAuthHelper = InstagramHelper()

View File

@@ -0,0 +1,57 @@
package org.jeudego.pairgoth.util
import java.nio.charset.Charset
import javax.crypto.Cipher
import javax.crypto.Cipher.DECRYPT_MODE
import javax.crypto.Cipher.ENCRYPT_MODE
import javax.crypto.SecretKey
import javax.crypto.spec.SecretKeySpec
/**
* Basic AES encryption. Please note that it uses the ECB block mode, which has the advantage
* to not require random bytes, thus providing some *persistence* for the encrypted data, but
* at the expense of some security weaknesses. The purpose here is just to encrypt temporary
* session ids in URLs, not to protect state secrets.
*/
class AESCryptograph : Cryptograph {
override fun init(key: String) {
val bytes = key.toByteArray(Charset.defaultCharset())
if (bytes.size < 16) {
throw Error("not enough secret bytes")
}
val secret: SecretKey = SecretKeySpec(bytes, 0, 16, ALGORITHM)
try {
encrypt.init(ENCRYPT_MODE, secret)
decrypt.init(DECRYPT_MODE, secret)
} catch (e: Exception) {
throw RuntimeException("cyptograph initialization failed", e)
}
}
override fun encrypt(str: String): ByteArray {
return try {
encrypt.doFinal(str.toByteArray(Charset.defaultCharset()))
} catch (e: Exception) {
throw RuntimeException("encryption failed failed", e)
}
}
override fun decrypt(bytes: ByteArray): String {
return try {
String(decrypt.doFinal(bytes), Charset.defaultCharset())
} catch (e: Exception) {
throw RuntimeException("encryption failed failed", e)
}
}
private var encrypt = Cipher.getInstance(CIPHER)
private var decrypt = Cipher.getInstance(CIPHER)
companion object {
private val CIPHER = "AES/ECB/PKCS5Padding"
private val ALGORITHM = "AES"
}
}

View File

@@ -0,0 +1,341 @@
package org.jeudego.pairgoth.util.ApiClient
import org.jeudego.pairgoth.util.parse
import com.republicate.kson.Json
import org.apache.http.*
import org.apache.http.client.ClientProtocolException
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.client.entity.UrlEncodedFormEntity
import org.apache.http.client.methods.*
import org.apache.http.config.SocketConfig
import org.apache.http.conn.ssl.SSLConnectionSocketFactory
import org.apache.http.entity.ContentType
import org.apache.http.entity.EntityTemplate
import org.apache.http.entity.StringEntity
import org.apache.http.entity.mime.HttpMultipartMode
import org.apache.http.entity.mime.MultipartEntityBuilder
import org.apache.http.entity.mime.content.StringBody
import org.apache.http.impl.client.HttpClients
import org.apache.http.message.BasicHeader
import org.apache.http.message.BasicNameValuePair
import org.apache.http.ssl.SSLContexts
import org.apache.http.util.EntityUtils
import org.slf4j.LoggerFactory
import org.w3c.dom.Element
import org.xml.sax.InputSource
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStreamReader
import java.io.OutputStream
import java.io.StringReader
import java.net.ProtocolException
import java.net.URLDecoder
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
import javax.xml.parsers.DocumentBuilderFactory
/**
* This class implements a basic API client around Apache HTTP client.
*/
val API_CLIENT_TIMEOUT = 60000
// TODO cookieStore ? credentialsProvider ?
// CookieStore cookieStore = new BasicCookieStore();
// CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
private val client = HttpClients.custom()
.setSSLSocketFactory(
SSLConnectionSocketFactory(
SSLContexts.createSystemDefault(), arrayOf("TLSv1.2"),
null,
SSLConnectionSocketFactory.getDefaultHostnameVerifier()
)
)
.setConnectionTimeToLive(1, TimeUnit.MINUTES)
.setDefaultSocketConfig(
SocketConfig.custom()
.setSoTimeout(API_CLIENT_TIMEOUT)
.build()
)
.setDefaultRequestConfig(
RequestConfig.custom()
.setConnectTimeout(API_CLIENT_TIMEOUT)
.setSocketTimeout(API_CLIENT_TIMEOUT)
.setCookieSpec(CookieSpecs.STANDARD)
.build()
)
.build()
// CB TODO - this should go elsewhere
fun ByteArray.reader(charset: Charset) =
BufferedReader(InputStreamReader(ByteArrayInputStream(this), charset))
fun header(name: String, value: String) = BasicHeader(name, value)
fun param(name: String, value: String) = BasicNameValuePair(name, value)
// Incomplete list of binary content types, that we should avoid to log
fun ContentType.isBinary() = mimeType.startsWith("image/") || mimeType.endsWith("pdf")
fun ContentType.isText() = !isBinary()
data class BinaryData(val contentType: ContentType, val data: ByteArray, val name: String, val filename: String? = null)
fun buildMultiPartBody(payload: Json.Object, binaryData: BinaryData? = null): HttpEntity {
val builder = MultipartEntityBuilder.create()
builder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
for (entry in payload.entries) {
builder.addPart(entry.key, StringBody(entry.value.toString(), ContentType.MULTIPART_FORM_DATA))
}
binaryData?.let {
builder.addBinaryBody(it.name, it.data, it.contentType, it.filename ?: "file")
}
return builder.build()
}
abstract class BaseApiClient<T> {
companion object {
internal var logger = LoggerFactory.getLogger("api")
}
private fun <B> build(method: String, url: String, body: B? = null, vararg keyValues: NameValuePair): HttpRequestBase {
val headers = mutableListOf<Header>()
val params = mutableListOf<NameValuePair>()
for (pair in keyValues) {
when (pair) {
is Header -> headers.add(pair)
else -> params.add(pair)
}
}
val req: HttpRequestBase = when (method) {
"GET" -> {
val paramsString = params.map { "${it.name}=${it.value}" }.joinToString("&")
val finalURL = url + (if (url.contains('?')) '&' else '?' ) + paramsString
HttpGet(finalURL)
}
"POST" -> {
HttpPost(url).also { req ->
if (params.isNotEmpty() && body != null) {
throw ClientProtocolException("specify POST body or POST parameters but not both")
}
if (params.isNotEmpty()) {
val entity = UrlEncodedFormEntity(params, "UTF-8")
req.entity = entity
} else if (body != null) {
val entity = if (body is HttpEntity) body
else StringEntity(body.toString(),
if (body is Json) ContentType.APPLICATION_JSON
else ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8))
req.entity = entity
}
}
}
"PATCH" -> {
HttpPatch(url).also { req ->
if (params.isNotEmpty() && body != null) {
throw ClientProtocolException("specify PATCH body or PATCH parameters but not both")
}
if (params.isNotEmpty()) {
val entity = UrlEncodedFormEntity(params, "UTF-8")
req.entity = entity
} else if (body != null) {
val entity = if (body is HttpEntity) body
else EntityTemplate { outputstream: OutputStream ->
outputstream.write(body.toString().toByteArray())
outputstream.flush()
}.also {
it.setContentType(ContentType.APPLICATION_JSON.toString())
}
req.entity = entity
}
}
}
"DELETE" -> {
HttpDelete(url).also { req ->
if (params.isNotEmpty() || body != null) {
throw ClientProtocolException("DELETE body or DELETE parameters not supported")
}
}
}
else -> throw ClientProtocolException("unhandled method: $method")
}
headers.addAll(acceptHeaders())
headers.forEach {
req.addHeader(it)
}
return req
}
private fun submit(req: HttpRequestBase): Pair<ByteArray, ContentType> {
try {
val resp: HttpResponse = client.execute(req)
val statusLine = resp.statusLine
val status = statusLine.statusCode
when {
status in 200..299 -> {
if (status != 204 && resp.entity == null) throw ClientProtocolException("Response is empty")
val body = resp.entity?.let { EntityUtils.toByteArray(it) }
val contentType = resp.entity?.let { ContentType.get(it) }
if (logger.isTraceEnabled) traceResponse(resp, body?.toString(contentType?.charset ?: StandardCharsets.UTF_8), contentType)
return Pair(body ?: "{}".toByteArray(StandardCharsets.UTF_8), contentType ?: ContentType.APPLICATION_JSON.withCharset(StandardCharsets.UTF_8))
}
else -> {
val body = resp.entity?.let { EntityUtils.toString(it) }
val contentType = resp.entity?.let { ContentType.get(it) }
if (logger.isTraceEnabled) traceResponse(resp, body, contentType)
var message = statusLine.toString()
if (body != null) message = "$message - $body"
throw IOException(message)
}
}
} finally {
req.releaseConnection()
}
}
private fun traceRequest(req: HttpRequestBase, body: String? = null) {
logger.trace(">> ${req.method} ${req.uri}")
for (header in req.allHeaders) {
logger.trace(">> $header")
}
if (body != null) {
val contentType = req.allHeaders.firstOrNull { it.name == HttpHeaders.CONTENT_TYPE }?.let {
ContentType.parse(it.value)
}
if (contentType?.isText() ?: true) {
for (line in body.split(Regex("[\r\n]"))) {
logger.trace(">> $line")
}
} else {
logger.trace(">> ${contentType?.toString() ?: "unknown mime type"}, ${body.length} bytes")
}
}
}
private fun traceResponse(resp: HttpResponse, body: String?, contentType: ContentType?) {
val statusLine = resp.statusLine
logger.trace("<< ${statusLine.statusCode} ${statusLine.reasonPhrase}")
for (header in resp.allHeaders) logger.trace("<< $header")
if (body != null) {
val knownContentType = contentType ?: resp.allHeaders.firstOrNull { it.name == HttpHeaders.CONTENT_TYPE }?.let {
ContentType.parse(it.value)
}
if (knownContentType?.isText() ?: true) {
for (line in body.split(Regex("[\r\n]"))) {
logger.trace("<< $line")
}
} else {
logger.trace("<< ${contentType?.toString() ?: "unknown mime type"}, ${body.length} bytes")
}
}
}
protected abstract fun acceptHeaders(): List<Header>
protected abstract fun parseResult(result: Pair<ByteArray, ContentType>): T
fun get(url: String, vararg with: NameValuePair): T {
val req = build("GET", url, null, *with)
if (logger.isTraceEnabled) traceRequest(req)
val result = submit(req)
return parseResult(result)
}
fun <B> post(url: String, body: B?, vararg with: NameValuePair): T {
val req = build("POST", url, body, *with)
if (logger.isTraceEnabled) traceRequest(req, body?.toString())
val result = submit(req)
return parseResult(result)
}
fun <B> patch(url: String, body: B?, vararg with: NameValuePair): T {
val req = build("PATCH", url, body, *with)
if (logger.isTraceEnabled) traceRequest(req, body?.toString())
val result = submit(req)
return parseResult(result)
}
fun <B> delete(url: String, body: B?, vararg with: NameValuePair): T {
val req = build("DELETE", url, body, *with)
if (logger.isTraceEnabled) traceRequest(req, body?.toString())
val result = submit(req)
return parseResult(result)
}
}
object AgnosticApiClient: BaseApiClient<Pair<ByteArray, ContentType>>() {
override fun acceptHeaders() = listOf(BasicHeader(HttpHeaders.ACCEPT, "*/*"))
override fun parseResult(result: Pair<ByteArray, ContentType>) = result
}
object JsonApiClient: BaseApiClient<Json>() {
override fun acceptHeaders(): List<Header> {
return listOf(BasicHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.mimeType))
}
override fun parseResult(result: Pair<ByteArray, ContentType>): Json {
when (result.second.mimeType) {
ContentType.APPLICATION_JSON.mimeType, "application/vnd.api+json" -> {
return Json.parse(result.first.reader(result.second.charset ?: StandardCharsets.UTF_8))!!
}
ContentType.APPLICATION_FORM_URLENCODED.mimeType -> {
val json = Json.MutableObject()
val charset = result.second.charset ?: StandardCharsets.UTF_8
val decoded = URLDecoder.decode(result.first.toString(charset), charset.name())
decoded.split("&").forEach {
val keyValue = it.split(Regex("="), 2)
if (keyValue.size != 2) throw ProtocolException("expecting a key-value pair: $it")
val prev = json.put(keyValue[0], keyValue[1])
if (prev != null) throw ClientProtocolException("Unsupported redundant values in response for key: " + keyValue[0]);
}
return json
}
else -> throw ClientProtocolException("invalid content type: ${result.second.mimeType}")
}
}
}
// CB TODO - needs to factorize XmlUtils with server => needs a common module
//
//object XmlApiClient: BaseApiClient<Element>() {
//
// override fun acceptHeaders(): List<Header> {
// return listOf(
// BasicHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_XML.mimeType),
// BasicHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_SOAP_XML.mimeType)
// )
// }
//
// override fun parseResult(result: Pair<ByteArray, ContentType>): Element {
// when (result.second.mimeType) {
// ContentType.APPLICATION_XML.mimeType, ContentType.APPLICATION_SOAP_XML.mimeType -> {
// return XmlUtils.parse(result.first.reader(result.second.charset ?: StandardCharsets.UTF_8)) ?: throw ClientProtocolException("empty XML body")
// }
// else -> throw ClientProtocolException("invalid content type: ${result.second.mimeType}")
// }
// }
//}
//
//object SoapTextApiClient: BaseApiClient<Element>() {
//
// override fun acceptHeaders() = listOf(BasicHeader(HttpHeaders.ACCEPT, "*/*"))
//
// override fun parseResult(result: Pair<ByteArray, ContentType>): Element {
// when (result.second.mimeType) {
// ContentType.TEXT_XML.mimeType -> {
// return XmlUtils.parse(result.first.reader(result.second.charset ?: StandardCharsets.UTF_8)) ?: throw ClientProtocolException("empty XML body")
// }
// else -> throw ClientProtocolException("invalid content type: ${result.second.mimeType}")
// }
// }
//}
//

View File

@@ -0,0 +1,29 @@
package org.jeudego.pairgoth.util
import java.io.Serializable
/**
* Cryptograph - used to encrypt and decrypt strings.
*
*/
interface Cryptograph : Serializable {
/**
* init.
* @param random random string
*/
fun init(random: String)
/**
* encrypt.
* @param str string to encrypt
* @return encrypted string
*/
fun encrypt(str: String): ByteArray
/**
* decrypt.
* @param bytes to decrypt
* @return decrypted string
*/
fun decrypt(bytes: ByteArray): String
}

View File

@@ -67,8 +67,14 @@ class WebappManager : ServletContextListener, ServletContextAttributeListener, H
logger.info("pairgoth server ${properties["version"]} with profile ${properties["env"]}") logger.info("pairgoth server ${properties["version"]} with profile ${properties["env"]}")
// publish some properties to the webapp context; for easy access from the template // publish some properties to the webapp context; for easy access from the template
context.setAttribute("env", properties["env"]) context.setAttribute("env", properties.getProperty("env") ?: "dev")
context.setAttribute("version", properties["version"] ?: "?") context.setAttribute("version", properties.getProperty("version") ?: "?")
properties.get("auth")?.let {
}
properties.getProperty("oauth.providers")?.let {
context.setAttribute("oauth.providers", it.split(Regex("\\s*,\\s*")))
}
// set system user agent string to empty string // set system user agent string to empty string
System.setProperty("http.agent", "") System.setProperty("http.agent", "")