Added authentication #4

Merged
mt merged 2 commits from feat/authentication into main 2026-09-04 10:57:34 +00:00
10 changed files with 220 additions and 67 deletions
Showing only changes of commit a1c01b5112 - Show all commits

View file

@ -19,20 +19,26 @@ dependencies {
implementation(ktorLibs.server.config.yaml) implementation(ktorLibs.server.config.yaml)
implementation(ktorLibs.server.core) implementation(ktorLibs.server.core)
implementation(ktorLibs.server.netty) implementation(ktorLibs.server.netty)
// JWT Authentication
implementation("io.ktor:ktor-server-auth")
implementation("io.ktor:ktor-server-auth-jwt")
implementation(libs.logback.classic) implementation(libs.logback.classic)
// JSON-Unterstützung und Serialization // JSON-Unterstützung und Serialization
implementation("io.ktor:ktor-server-content-negotiation-jvm") implementation("io.ktor:ktor-server-content-negotiation-jvm")
implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm")
//postgres
// postgres
implementation("org.postgresql:postgresql:42.7.3") implementation("org.postgresql:postgresql:42.7.3")
implementation("org.jetbrains.exposed:exposed-core:0.50.0") implementation("org.jetbrains.exposed:exposed-core:0.50.0")
implementation("org.jetbrains.exposed:exposed-dao:0.50.0") implementation("org.jetbrains.exposed:exposed-dao:0.50.0")
implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0") implementation("org.jetbrains.exposed:exposed-jdbc:0.50.0")
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
testImplementation(ktorLibs.server.testHost) testImplementation(ktorLibs.server.testHost)
//redis // redis
implementation("redis.clients:jedis:5.1.2") implementation("redis.clients:jedis:5.1.2")
} }

View file

@ -24,6 +24,14 @@ object ScreentimeTable : Table("user_screentime") {
override val primaryKey = PrimaryKey(userId) 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 { object DatabaseFactory {
fun init(config: ApplicationConfig) { fun init(config: ApplicationConfig) {
val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver" val driverClassName = config.propertyOrNull("storage.driverClassName")?.getString() ?: "org.postgresql.Driver"
@ -40,7 +48,7 @@ object DatabaseFactory {
?: config.propertyOrNull("storage.database")?.getString() ?: config.propertyOrNull("storage.database")?.getString()
?: "kidio_db" ?: "kidio_db"
val username = System.getenv("DB_USER") val dbUsername = System.getenv("DB_USER")
?: config.propertyOrNull("storage.username")?.getString() ?: config.propertyOrNull("storage.username")?.getString()
?: "postgres" ?: "postgres"
@ -53,12 +61,12 @@ object DatabaseFactory {
val database = Database.connect( val database = Database.connect(
url = jdbcURL, url = jdbcURL,
driver = driverClassName, driver = driverClassName,
user = username, user = dbUsername,
password = password password = password
) )
transaction(database) { transaction(database) {
SchemaUtils.create(TasksTable, ScreentimeTable) SchemaUtils.create(TasksTable, ScreentimeTable, UserTable)
// Standard aufgaben, sollte es keine mehr geben // Standard aufgaben, sollte es keine mehr geben
if (TasksTable.selectAll().empty()) { if (TasksTable.selectAll().empty()) {
@ -86,6 +94,16 @@ object DatabaseFactory {
it[parentPin] = "1234" 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"
}
}
} }
} }

View file

@ -33,6 +33,18 @@ data class SyncRequest(
val usedSecondsSinceLastSync: Int val usedSecondsSinceLastSync: Int
) )
@Serializable
data class LoginRequest(
val userId: String
)
@Serializable
data class User(
val id: String,
val username: String,
val passwordHash: String
)
@Serializable @Serializable
data class SyncResponse( data class SyncResponse(
val remainingSeconds: Int, val remainingSeconds: Int,

View file

@ -1,10 +1,12 @@
package com.oliver.kidio.backend.domain.repository package com.oliver.kidio.backend.domain.repository
import com.oliver.kidio.backend.Task 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.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.data.database.ScreentimeTable import com.oliver.kidio.backend.data.database.ScreentimeTable
import com.oliver.kidio.backend.data.database.TasksTable import com.oliver.kidio.backend.data.database.TasksTable
import com.oliver.kidio.backend.data.database.UserTable
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
interface KidioRepository { interface KidioRepository {
@ -12,6 +14,9 @@ interface KidioRepository {
suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>?
suspend fun getScreentime(userId: String): Int suspend fun getScreentime(userId: String): Int
suspend fun updateScreentime(userId: String, seconds: 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 { class ExposedKidioRepository : KidioRepository {
@ -34,7 +39,7 @@ class ExposedKidioRepository : KidioRepository {
} }
override suspend fun getScreentime(userId: String): Int { override suspend fun getScreentime(userId: String): Int {
var redisKey = "screentime:$userId" val redisKey = "screentime:$userId"
try { try {
RedisFactory.getResource().use { jedis -> 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 { class InMemoryKidioRepository : KidioRepository {
@ -91,6 +113,10 @@ class InMemoryKidioRepository : KidioRepository {
"default_user" to 1800 "default_user" to 1800
) )
private val users = mutableListOf(
User(id = "1", username = "max", passwordHash = "geheim123")
)
override suspend fun getAllTasks(): List<Task> = tasks override suspend fun getAllTasks(): List<Task> = tasks
override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId] override suspend fun getTaskAnswerAndReward(taskId: String): Pair<String, Int>? = answers[taskId]
@ -100,4 +126,12 @@ class InMemoryKidioRepository : KidioRepository {
override suspend fun updateScreentime(userId: String, seconds: Int) { override suspend fun updateScreentime(userId: String, seconds: Int) {
screentimes[userId] = seconds 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
}
} }

View file

@ -2,12 +2,14 @@ package com.oliver.kidio.backend.plugins
import com.oliver.kidio.backend.* import com.oliver.kidio.backend.*
import com.oliver.kidio.backend.domain.repository.KidioRepository import com.oliver.kidio.backend.domain.repository.KidioRepository
import com.oliver.kidio.backend.security.JwtService
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.auth.authenticate
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
fun Application.configureRouting(repository: KidioRepository) { fun Application.configureRouting(repository: KidioRepository, jwtService : JwtService) {
routing { routing {
// Health-Check // Health-Check
@ -15,6 +17,15 @@ fun Application.configureRouting(repository: KidioRepository) {
call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!")) call.respond(mapOf("status" to "OK", "message" to "Kidio Backend läuft!"))
} }
post ("/api/v1/auth/login" ) {
val loginRequest = call.receive<LoginRequest>()
val token = jwtService.generateToken(userId = loginRequest.userId)
call.respond(mapOf("token" to token))
}
authenticate("auth-jwt") {
// 1. Alle verfügbaren Aufgaben abrufen // 1. Alle verfügbaren Aufgaben abrufen
get("/api/v1/tasks") { get("/api/v1/tasks") {
val tasks = repository.getAllTasks() val tasks = repository.getAllTasks()
@ -82,4 +93,6 @@ fun Application.configureRouting(repository: KidioRepository) {
) )
} }
} }
}
} }

View file

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

View file

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

View file

@ -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.DatabaseFactory
import com.oliver.kidio.backend.data.database.RedisFactory import com.oliver.kidio.backend.data.database.RedisFactory
import com.oliver.kidio.backend.domain.repository.ExposedKidioRepository 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.serialization.kotlinx.json.*
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.contentnegotiation.*
@ -16,6 +18,13 @@ fun Application.module() {
json() json()
} }
// Ruft die Funktion aus Routing.kt auf mit dem echten ExposedRepository val jwtService = JwtService()
configureRouting(ExposedKidioRepository()) val repository = ExposedKidioRepository()
configureSecurity(jwtService)
configureRouting(
repository = repository,
jwtService = jwtService
)
} }