Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1433: Create user hashing for Koblenz #1499

Merged
merged 12 commits into from
Jul 30, 2024
4 changes: 4 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ dependencies {
// Use the Kotlin JUnit integration.
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")

testImplementation("io.mockk:mockk:1.13.11")

implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
Expand All @@ -74,6 +76,8 @@ dependencies {
implementation("com.auth0:java-jwt:4.4.0") // JSON web tokens
implementation("at.favre.lib:bcrypt:0.10.2")

implementation("org.bouncycastle:bcpkix-jdk18on:1.76")

implementation("com.google.zxing:core:3.5.2") // QR-Codes
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.ehrenamtskarte.backend.common.utils

// This helper class was created to enable mocking getenv in Tests
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
class Environment {
companion object {
fun getVariable(name: String): String? = System.getenv(name)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ package app.ehrenamtskarte.backend.common.webservice

const val EAK_BAYERN_PROJECT = "bayern.ehrenamtskarte.app"
const val NUERNBERG_PASS_PROJECT = "nuernberg.sozialpass.app"
const val KOBLENZ_PASS_PROJECT = "koblenz.sozialpass.app"
const val KOBLENZ_PEPPER_SYS_ENV = "KOBLENZ_PEPPER"
const val SHOWCASE_PROJECT = "showcase.entitlementcard.app"
const val DEFAULT_PROJECT = EAK_BAYERN_PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
package app.ehrenamtskarte.backend.exception.service

class NotEakProjectException() : Exception("This query can only be used for EAK project")

class NotKoblenzProjectException() : Exception("This query can only be used for Koblenz project")
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
package app.ehrenamtskarte.backend.regions.database

import app.ehrenamtskarte.backend.common.webservice.EAK_BAYERN_PROJECT
import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PASS_PROJECT
import app.ehrenamtskarte.backend.common.webservice.NUERNBERG_PASS_PROJECT
import app.ehrenamtskarte.backend.projects.database.ProjectEntity
import org.jetbrains.exposed.sql.transactions.transaction

fun insertOrUpdateRegions() {
transaction {
val projects = ProjectEntity.all()
val dbRegions = RegionEntity.all()
val projects = ProjectEntity.all()
val dbRegions = RegionEntity.all()

fun createOrUpdateRegion(
regionProjectId: String,
regionName: String,
regionPrefix: String,
regionWebsite: String
) {
val project =
projects.firstOrNull { it.project == regionProjectId }
?: throw Error("Required project '$regionProjectId' not found!")
val region = dbRegions.singleOrNull { it.projectId == project.id }
if (region == null) {
RegionEntity.new {
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
projectId = project.id
name = regionName
prefix = regionPrefix
regionIdentifier = null
website = regionWebsite
}
} else {
region.name = regionName
region.prefix = regionPrefix
region.website = regionWebsite
}
}

transaction {
// Create or update eak regions in database
val eakProject = projects.firstOrNull { it.project == EAK_BAYERN_PROJECT }
?: throw Error("Required project '$EAK_BAYERN_PROJECT' not found!")
val eakProject =
projects.firstOrNull { it.project == EAK_BAYERN_PROJECT }
?: throw Error("Required project '$EAK_BAYERN_PROJECT' not found!")
EAK_BAYERN_REGIONS.forEach { eakRegion ->
val dbRegion = dbRegions.find { it.regionIdentifier == eakRegion[2] && it.projectId == eakProject.id }
if (dbRegion == null) {
Expand All @@ -30,22 +57,7 @@ fun insertOrUpdateRegions() {
}
}

// Create or update nuernberg region in database
val nuernbergPassProject = projects.firstOrNull { it.project == NUERNBERG_PASS_PROJECT }
?: throw Error("Required project '$NUERNBERG_PASS_PROJECT' not found!")
val nuernbergRegion = dbRegions.singleOrNull { it.projectId == nuernbergPassProject.id }
if (nuernbergRegion == null) {
RegionEntity.new {
projectId = nuernbergPassProject.id
name = "Nürnberg"
prefix = "Stadt"
regionIdentifier = null
website = "https://nuernberg.de"
}
} else {
nuernbergRegion.name = "Nürnberg"
nuernbergRegion.prefix = "Stadt"
nuernbergRegion.website = "https://nuernberg.de"
}
createOrUpdateRegion(NUERNBERG_PASS_PROJECT, "Nürnberg", "Stadt", "https://nuernberg.de")
createOrUpdateRegion(KOBLENZ_PASS_PROJECT, "Koblenz", "Stadt", "https://koblenz.de/")
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package app.ehrenamtskarte.backend.user
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved

import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonPropertyOrder

@JsonPropertyOrder(alphabetic = true)
data class KoblenzUser(@get:JsonProperty("1") val fullname: String, @get:JsonProperty("2") val birthday: Int, @get:JsonProperty("3") val referenceNumber: String)
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@

import app.ehrenamtskarte.backend.common.utils.Environment
import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PEPPER_SYS_ENV
import app.ehrenamtskarte.backend.user.KoblenzUser
import app.ehrenamtskarte.backend.verification.CanonicalJson
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
import java.nio.charset.StandardCharsets
import java.util.Base64

class Argon2IdHasher {
companion object {
/**
* Copied from spring-security Argon2EncodingUtils.java licenced under Apache 2.0, removed the salt from the result
*
* Encodes a raw Argon2-hash and its parameters into the standard Argon2-hash-string
* as specified in the reference implementation
* (https://github.com/P-H-C/phc-winner-argon2/blob/master/src/encoding.c#L244):
*
* {@code $argon2<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>}
**/
@Throws(IllegalArgumentException::class)
fun encode(
hash: ByteArray?,
parameters: Argon2Parameters
): String? {
val b64encoder: Base64.Encoder = Base64.getEncoder().withoutPadding()
val stringBuilder = StringBuilder()
val type =
when (parameters.type) {
Argon2Parameters.ARGON2_d -> "\$argon2d"
Argon2Parameters.ARGON2_i -> "\$argon2i"
Argon2Parameters.ARGON2_id -> "\$argon2id"
else -> throw IllegalArgumentException("Invalid algorithm type: " + parameters.type)
}
stringBuilder.append(type)
stringBuilder
.append("\$v=")
.append(parameters.version)
.append("\$m=")
.append(parameters.memory)
.append(",t=")
.append(parameters.iterations)
.append(",p=")
.append(parameters.lanes)
stringBuilder.append("$").append(b64encoder.encodeToString(hash))
return stringBuilder.toString()
}

fun hashKoblenzUserData(userData: KoblenzUser): String? {
val canonicalJson = CanonicalJson.koblenzUserToString(userData)
val hashLength = 32

val pepper = Environment.getVariable(KOBLENZ_PEPPER_SYS_ENV) // TODO handle if Null
maxammann marked this conversation as resolved.
Show resolved Hide resolved
val pepperByteArray = pepper?.toByteArray(StandardCharsets.UTF_8)
val params =
Argon2Parameters
.Builder(Argon2Parameters.ARGON2_id)
.withVersion(19)
ztefanie marked this conversation as resolved.
Show resolved Hide resolved
.withIterations(2)
.withSalt(pepperByteArray)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
.withParallelism(1)
.withMemoryAsKB(16)
.build()

val generator = Argon2BytesGenerator()
generator.init(params)
val result = ByteArray(hashLength)
generator.generateBytes(canonicalJson.toByteArray(), result)
maxammann marked this conversation as resolved.
Show resolved Hide resolved
return encode(result, params)
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package app.ehrenamtskarte.backend.verification

import app.ehrenamtskarte.backend.user.KoblenzUser
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.protobuf.Descriptors
import com.google.protobuf.Descriptors.FieldDescriptor.Type
import com.google.protobuf.GeneratedMessageV3
Expand Down Expand Up @@ -89,6 +92,11 @@ class CanonicalJson {
}
}

fun koblenzUserToString(koblenzUser: KoblenzUser): String {
val map = ObjectMapper().convertValue(koblenzUser, object : TypeReference<Map<String, Any>>() {})
return serializeToString(map)
}

fun serializeToString(message: GeneratedMessageV3) = serializeToString(messageToMap(message))

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,36 @@ enum class CardInfoTestSample {
Nuernberg,
NuernbergWithStartDay,
NuernbergWithPassId,
NuernbergWithPassNr
NuernbergWithPassNr,
KoblenzPass
}

object ExampleCardInfo {
private val bavarianBase = buildCardInfo(
Card.CardInfo.getDefaultInstance(),
fullName = "Max Mustermann",
regionId = 16
)
private val bavarianBase =
buildCardInfo(
Card.CardInfo.getDefaultInstance(),
fullName = "Max Mustermann",
regionId = 16
)

private val nuernbergBase = buildCardInfo(
Card.CardInfo.getDefaultInstance(),
fullName = "Max Mustermann",
regionId = 93,
nuernbergPassId = 99999999,
birthDay = -365 * 10,
expirationDay = 365 * 40 // Equals 14.600
)
private val nuernbergBase =
buildCardInfo(
Card.CardInfo.getDefaultInstance(),
fullName = "Max Mustermann",
regionId = 93,
nuernbergPassId = 99999999,
birthDay = -365 * 10,
expirationDay = 365 * 40 // Equals 14.600
)

private val koblenzBase =
buildCardInfo(
Card.CardInfo.getDefaultInstance(),
fullName = "Karla Koblenz",
regionId = 95,
koblenzReferenceNumber = "123K",
birthDay = 12213 // 10.06.2003
)

private fun buildCardInfo(
base: Card.CardInfo,
Expand All @@ -34,6 +46,7 @@ object ExampleCardInfo {
birthDay: Int? = null,
nuernbergPassId: Int? = null,
nuernbergPassIdIdentifier: Card.NuernergPassIdentifier? = null,
koblenzReferenceNumber: String? = null,
startDay: Int? = null
): Card.CardInfo {
val cardInfo = Card.CardInfo.newBuilder(base)
Expand All @@ -49,40 +62,50 @@ object ExampleCardInfo {
nuernbergPassIdIdentifier
)
}
if (koblenzReferenceNumber != null) extensions.extensionKoblenzReferenceNumberBuilder.setReferenceNumber(koblenzReferenceNumber)
if (startDay != null) extensions.extensionStartDayBuilder.setStartDay(startDay)
return cardInfo.buildPartial()
}

fun get(cardInfoTestSample: CardInfoTestSample): Card.CardInfo {
return when (cardInfoTestSample) {
CardInfoTestSample.BavarianStandard -> buildCardInfo(
bavarianBase,
expirationDay = 365 * 40, // Equals 14.600
bavariaCardType = Card.BavariaCardType.STANDARD
)
fun get(cardInfoTestSample: CardInfoTestSample): Card.CardInfo =
when (cardInfoTestSample) {
CardInfoTestSample.BavarianStandard ->
buildCardInfo(
bavarianBase,
expirationDay = 365 * 40, // Equals 14.600
bavariaCardType = Card.BavariaCardType.STANDARD
)

CardInfoTestSample.BavarianGold -> buildCardInfo(
bavarianBase,
bavariaCardType = Card.BavariaCardType.GOLD
)
CardInfoTestSample.BavarianGold ->
buildCardInfo(
bavarianBase,
bavariaCardType = Card.BavariaCardType.GOLD
)

CardInfoTestSample.Nuernberg -> nuernbergBase
CardInfoTestSample.NuernbergWithStartDay -> buildCardInfo(
nuernbergBase,
startDay = 365 * 2
)
CardInfoTestSample.NuernbergWithStartDay ->
buildCardInfo(
nuernbergBase,
startDay = 365 * 2
)

CardInfoTestSample.NuernbergWithPassId -> buildCardInfo(
nuernbergBase,
nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passId,
startDay = 365 * 2
)
CardInfoTestSample.NuernbergWithPassId ->
buildCardInfo(
nuernbergBase,
nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passId,
startDay = 365 * 2
)

CardInfoTestSample.NuernbergWithPassNr -> buildCardInfo(
nuernbergBase,
nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passNr,
startDay = 365 * 2
)
CardInfoTestSample.NuernbergWithPassNr ->
buildCardInfo(
nuernbergBase,
nuernbergPassIdIdentifier = Card.NuernergPassIdentifier.passNr,
startDay = 365 * 2
)

CardInfoTestSample.KoblenzPass ->
buildCardInfo(
koblenzBase
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package app.ehrenamtskarte.backend.helper

import app.ehrenamtskarte.backend.user.KoblenzUser

val koblenzTestUser = KoblenzUser("Karla Koblenz", 12213, "123K")
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package app.ehrenamtskarte.backend.verification
import Argon2IdHasher
import app.ehrenamtskarte.backend.common.utils.Environment
import app.ehrenamtskarte.backend.common.webservice.KOBLENZ_PEPPER_SYS_ENV
import app.ehrenamtskarte.backend.user.KoblenzUser
import io.mockk.every
import io.mockk.mockkObject
import kotlin.test.Test
import kotlin.test.assertEquals

internal class Argon2IdHasherTest {
@Test
fun isHashingCorrectly() {
mockkObject(Environment)
every { Environment.getVariable(KOBLENZ_PEPPER_SYS_ENV) } returns "123456789ABC"

assertEquals(Environment.getVariable("KOBLENZ_PEPPER"), "123456789ABC")

val hash = Argon2IdHasher.hashKoblenzUserData(KoblenzUser("Karla Koblenz", 12213, "123K"))
val expectedHash = "\$argon2id\$v=19\$m=16,t=2,p=1\$MTIzNDU2Nzg5QUJD\$UIOJZIsSL8vXcuCB82xZ5E8tpH6sQd3d4U0uC02DP40" // This expected output was created with https://argon2.online/

assertEquals(expectedHash, hash)
}
}
Loading