diff --git a/.gradle/9.5.1/fileHashes/fileHashes.bin b/.gradle/9.5.1/fileHashes/fileHashes.bin index 800d512..cede7e6 100644 Binary files a/.gradle/9.5.1/fileHashes/fileHashes.bin and b/.gradle/9.5.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/9.5.1/fileHashes/fileHashes.lock b/.gradle/9.5.1/fileHashes/fileHashes.lock index 4153d38..c73a39b 100644 Binary files a/.gradle/9.5.1/fileHashes/fileHashes.lock and b/.gradle/9.5.1/fileHashes/fileHashes.lock differ diff --git a/build.gradle.kts b/build.gradle.kts index c424bcd..776f637 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,20 +19,26 @@ dependencies { implementation(ktorLibs.server.config.yaml) implementation(ktorLibs.server.core) implementation(ktorLibs.server.netty) + + // JWT Authentication + implementation("io.ktor:ktor-server-auth") + implementation("io.ktor:ktor-server-auth-jwt") + implementation(libs.logback.classic) // JSON-Unterstützung und Serialization implementation("io.ktor:ktor-server-content-negotiation-jvm") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") -//postgres + + // postgres implementation("org.postgresql:postgresql:42.7.3") implementation("org.jetbrains.exposed:exposed-core:0.50.0") implementation("org.jetbrains.exposed:exposed-dao:0.50.0") implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0") + testImplementation(kotlin("test")) testImplementation(ktorLibs.server.testHost) - //redis + // redis implementation("redis.clients:jedis:5.1.2") - } \ No newline at end of file diff --git a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt index 6067566..3015f1d 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/data/database/DatabaseFactory.kt @@ -24,6 +24,14 @@ object ScreentimeTable : Table("user_screentime") { override val primaryKey = PrimaryKey(userId) } +object UserTable : Table("users") { + val id = varchar("id", 36) + val username = varchar("username", 50).index(isUnique = true) + val passwordHash = varchar("password_hash", 255) + + override val primaryKey = PrimaryKey(id) +} + object DatabaseFactory { fun init(config: ApplicationConfig) { val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver" @@ -40,8 +48,8 @@ object DatabaseFactory { ?: config.propertyOrNull("storage.database")?.getString() ?: "kidio_db" - val username = System.getenv("DB_USER") - ?: config.propertyOrNull("storage.username")?.getString() + val dbUsername = System.getenv("DB_USER") + ?: config.propertyOrNull("storage.username")?.getString() ?: "postgres" val password = System.getenv("DB_PASSWORD") @@ -53,13 +61,13 @@ object DatabaseFactory { val database = Database.connect( url = jdbcURL, driver = driverClassName, - user = username, + user = dbUsername, password = password ) transaction(database) { - SchemaUtils.create(TasksTable, ScreentimeTable) - + SchemaUtils.create(TasksTable, ScreentimeTable, UserTable) + // Standard aufgaben, sollte es keine mehr geben if (TasksTable.selectAll().empty()) { TasksTable.insert { @@ -86,6 +94,16 @@ object DatabaseFactory { it[parentPin] = "1234" } } + + // Create a standard user if the Users table is empty. + if (UserTable.selectAll().empty()) { + UserTable.insert { + // ID automatische generierung + it[id] = "default_user" + it[username] = "default_user" + it[passwordHash] = "1234" + } + } } } diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt index 6bd88f0..9eef0ef 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/models/models.kt @@ -33,8 +33,20 @@ data class SyncRequest( val usedSecondsSinceLastSync: Int ) +@Serializable +data class LoginRequest( + val userId: String +) + +@Serializable +data class User( + val id: String, + val username: String, + val passwordHash: String +) + @Serializable data class SyncResponse( val remainingSeconds: Int, val isBlocked: Boolean -) \ No newline at end of file +) diff --git a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt index d0b1166..277ccb6 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/domain/repository/KidioRepository.kt @@ -1,10 +1,12 @@ package com.oliver.kidio.backend.domain.repository import com.oliver.kidio.backend.Task +import com.oliver.kidio.backend.User import com.oliver.kidio.backend.data.database.DatabaseFactory import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.data.database.ScreentimeTable import com.oliver.kidio.backend.data.database.TasksTable +import com.oliver.kidio.backend.data.database.UserTable import org.jetbrains.exposed.sql.* interface KidioRepository { @@ -12,6 +14,9 @@ interface KidioRepository { suspend fun getTaskAnswerAndReward(taskId: String): Pair? suspend fun getScreentime(userId: String): Int suspend fun updateScreentime(userId: String, seconds: Int) + + suspend fun findByUsername(username: String): User? + suspend fun verifyPassword(password: String, passwordHash: String): Boolean } class ExposedKidioRepository : KidioRepository { @@ -34,7 +39,7 @@ class ExposedKidioRepository : KidioRepository { } override suspend fun getScreentime(userId: String): Int { - var redisKey = "screentime:$userId" + val redisKey = "screentime:$userId" try { RedisFactory.getResource().use { jedis -> @@ -76,6 +81,23 @@ class ExposedKidioRepository : KidioRepository { } } } + + override suspend fun findByUsername(username: String): User? = DatabaseFactory.dbQuery { + UserTable.selectAll() + .where { UserTable.username eq username } + .map { + User( + id = it[UserTable.id], + username = it[UserTable.username], + passwordHash = it[UserTable.passwordHash] + ) + } + .singleOrNull() + } + + override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { + return password == passwordHash + } } class InMemoryKidioRepository : KidioRepository { @@ -91,6 +113,10 @@ class InMemoryKidioRepository : KidioRepository { "default_user" to 1800 ) + private val users = mutableListOf( + User(id = "1", username = "max", passwordHash = "geheim123") + ) + override suspend fun getAllTasks(): List = tasks override suspend fun getTaskAnswerAndReward(taskId: String): Pair? = answers[taskId] @@ -100,4 +126,12 @@ class InMemoryKidioRepository : KidioRepository { override suspend fun updateScreentime(userId: String, seconds: Int) { screentimes[userId] = seconds } + + override suspend fun findByUsername(username: String): User? { + return users.find { it.username == username } + } + + override suspend fun verifyPassword(password: String, passwordHash: String): Boolean { + return password == passwordHash + } } diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt index 23da00e..eddad2f 100644 --- a/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Routing.kt @@ -2,12 +2,14 @@ package com.oliver.kidio.backend.plugins import com.oliver.kidio.backend.* import com.oliver.kidio.backend.domain.repository.KidioRepository +import com.oliver.kidio.backend.security.JwtService import io.ktor.server.application.* +import io.ktor.server.auth.authenticate import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -fun Application.configureRouting(repository: KidioRepository) { +fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) { routing { // Health-Check @@ -15,71 +17,82 @@ fun Application.configureRouting(repository: KidioRepository) { call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) } - // 1. Alle verfügbaren Aufgaben abrufen - get("/api/v1/tasks") { - val tasks = repository.getAllTasks() - call.respond(tasks) + post ("/api/v1/auth/login" ) { + val loginRequest = call.receive() + + val token = jwtService.generateToken(userId = loginRequest.userId) + call.respond(mapOf("token" to token)) } - // 2. Antwort auf eine Aufgabe überprüfen - post("/api/v1/tasks/verify") { - val request = call.receive() + authenticate("auth-jwt") { - val taskInfo = repository.getTaskAnswerAndReward(request.taskId) - - if (taskInfo == null) { - call.respond( - TaskVerifyResponse( - isCorrect = false, - earnedMinutes = 0, - message = "Aufgabe nicht gefunden!" - ) - ) - return@post + // 1. Alle verfügbaren Aufgaben abrufen + get("/api/v1/tasks") { + val tasks = repository.getAllTasks() + call.respond(tasks) } - val (correctAnswer, reward) = taskInfo - val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim() + // 2. Antwort auf eine Aufgabe überprüfen + post("/api/v1/tasks/verify") { + val request = call.receive() - if (isCorrect) { - val currentScreentime = repository.getScreentime("default_user") - val newSeconds = currentScreentime + (reward * 60) - repository.updateScreentime("default_user", newSeconds) + val taskInfo = repository.getTaskAnswerAndReward(request.taskId) - call.respond( - TaskVerifyResponse( - isCorrect = true, - earnedMinutes = reward, - message = "Super gemacht! Du hast $reward Minuten gewonnen." + if (taskInfo == null) { + call.respond( + TaskVerifyResponse( + isCorrect = false, + earnedMinutes = 0, + message = "Aufgabe nicht gefunden!" + ) ) - ) - } else { + return@post + } + + val (correctAnswer, reward) = taskInfo + val isCorrect = request.selectedAnswer.trim() == correctAnswer.trim() + + if (isCorrect) { + val currentScreentime = repository.getScreentime("default_user") + val newSeconds = currentScreentime + (reward * 60) + repository.updateScreentime("default_user", newSeconds) + + call.respond( + TaskVerifyResponse( + isCorrect = true, + earnedMinutes = reward, + message = "Super gemacht! Du hast $reward Minuten gewonnen." + ) + ) + } else { + call.respond( + TaskVerifyResponse( + isCorrect = false, + earnedMinutes = 0, + message = "Schade, das war leider falsch. Versuche es nochmal!" + ) + ) + } + } + + // 3. Bildschirmzeit synchronisieren + post("/api/v1/screentime/sync") { + val request = call.receive() + + val currentSeconds = repository.getScreentime(request.userId) + var newSeconds = currentSeconds - request.usedSecondsSinceLastSync + if (newSeconds < 0) newSeconds = 0 + + repository.updateScreentime(request.userId, newSeconds) + call.respond( - TaskVerifyResponse( - isCorrect = false, - earnedMinutes = 0, - message = "Schade, das war leider falsch. Versuche es nochmal!" + SyncResponse( + remainingSeconds = newSeconds, + isBlocked = newSeconds <= 0 ) ) } } - - // 3. Bildschirmzeit synchronisieren - post("/api/v1/screentime/sync") { - val request = call.receive() - - val currentSeconds = repository.getScreentime(request.userId) - var newSeconds = currentSeconds - request.usedSecondsSinceLastSync - if (newSeconds < 0) newSeconds = 0 - - repository.updateScreentime(request.userId, newSeconds) - - call.respond( - SyncResponse( - remainingSeconds = newSeconds, - isBlocked = newSeconds <= 0 - ) - ) - } } } + diff --git a/src/main/kotlin/com/oliver/kidio/backend/plugins/Security.kt b/src/main/kotlin/com/oliver/kidio/backend/plugins/Security.kt new file mode 100644 index 0000000..cd05f67 --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/plugins/Security.kt @@ -0,0 +1,29 @@ +package com.oliver.kidio.backend.plugins + +import com.oliver.kidio.backend.security.JwtService +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.auth.jwt.* + +/** + * Repräsentiert den authentifizierten Benutzer im Request-Kontext. + */ +data class UserPrincipal(val userId: String) : Principal + +fun Application.configureSecurity(jwtService: JwtService) { + install(Authentication) { + jwt("auth-jwt") { + realm = "Kidio Backend" + verifier(jwtService.verifier) + + validate { credential -> + val userId = credential.payload.getClaim("userId").asString() + if (!userId.isNullOrEmpty()) { + UserPrincipal(userId) + } else { + null + } + } + } + } +} diff --git a/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt b/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt new file mode 100644 index 0000000..f6c780c --- /dev/null +++ b/src/main/kotlin/com/oliver/kidio/backend/security/JwtService.kt @@ -0,0 +1,32 @@ +package com.oliver.kidio.backend.security + +import com.auth0.jwt.JWT +import com.auth0.jwt.JWTVerifier +import com.auth0.jwt.algorithms.Algorithm +import java.util.Date + +class JwtService( + private val secret: String = System.getenv("JWT_SECRET") ?: "super-geheimes-secret-fuer-dev", + private val issuer: String = "https://kidio.oliver.com/", + private val audience: String = "kidio-users", + private val expirationInMs: Long = 36_000_000 // 10 Stunden +) { + + val verifier: JWTVerifier = JWT + .require(Algorithm.HMAC256(secret)) + .withAudience(audience) + .withIssuer(issuer) + .build() + + /** + * Generiert ein JWT-Token für einen bestimmten Benutzer. + */ + fun generateToken(userId: String): String { + return JWT.create() + .withAudience(audience) + .withIssuer(issuer) + .withClaim("userId", userId) + .withExpiresAt(Date(System.currentTimeMillis() + expirationInMs)) + .sign(Algorithm.HMAC256(secret)) + } +} diff --git a/src/main/kotlin/main.kt b/src/main/kotlin/main.kt index 3831b3c..dd67d89 100644 --- a/src/main/kotlin/main.kt +++ b/src/main/kotlin/main.kt @@ -2,6 +2,8 @@ import com.oliver.kidio.backend.plugins.configureRouting import com.oliver.kidio.backend.data.database.DatabaseFactory import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository +import com.oliver.kidio.backend.plugins.configureSecurity +import com.oliver.kidio.backend.security.JwtService import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.contentnegotiation.* @@ -16,6 +18,13 @@ fun Application.module() { json() } - // Ruft die Funktion aus Routing.kt auf mit dem echten ExposedRepository - configureRouting(ExposedKidioRepository()) -} \ No newline at end of file + val jwtService = JwtService() + val repository = ExposedKidioRepository() + + configureSecurity(jwtService) + + configureRouting( + repository = repository, + jwtService = jwtService + ) +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 449ee2c..dcf0bd7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,5 +10,5 @@ storage: host: "82.165.11.162" port: "5432" database: "kidio_db" - username: "postgres" + username: "kidio_admin" password: "learning"