diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt index d9b42c4..a95c217 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt @@ -3,10 +3,7 @@ package com.wbrawner.twigs import com.wbrawner.twigs.model.PasswordResetToken import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.User -import com.wbrawner.twigs.storage.PasswordResetRepository -import com.wbrawner.twigs.storage.PermissionRepository -import com.wbrawner.twigs.storage.SessionRepository -import com.wbrawner.twigs.storage.UserRepository +import com.wbrawner.twigs.storage.* import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.auth.* @@ -20,16 +17,17 @@ fun Application.userRoutes( passwordResetRepository: PasswordResetRepository, permissionRepository: PermissionRepository, sessionRepository: SessionRepository, - userRepository: UserRepository + userRepository: UserRepository, + passwordHasher: PasswordHasher ) { routing { route("/api/users") { post("/login") { val request = call.receive() val user = - userRepository.findAll(nameOrEmail = request.username, password = request.password.hash()) + userRepository.findAll(nameOrEmail = request.username, password = passwordHasher.hash(request.password)) .firstOrNull() - ?: userRepository.findAll(nameOrEmail = request.username, password = request.password.hash()) + ?: userRepository.findAll(nameOrEmail = request.username, password = passwordHasher.hash(request.password)) .firstOrNull() ?: run { errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials") @@ -65,7 +63,7 @@ fun Application.userRoutes( userRepository.save( User( name = request.username, - password = request.password.hash(), + password = passwordHasher.hash(request.password), email = if (request.email.isNullOrBlank()) "" else request.email ) ).asResponse() @@ -74,9 +72,12 @@ fun Application.userRoutes( authenticate(optional = false) { get { - val query = call.request.queryParameters.getAll("query") - if (query?.firstOrNull()?.isNotBlank() == true) { - call.respond(userRepository.findAll(nameLike = query.first()).map { it.asResponse() }) + val query = call.request.queryParameters["query"] + if (query != null) { + if (query.isBlank()) { + errorResponse(HttpStatusCode.BadRequest, "query cannot be empty") + } + call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() }) return@get } permissionRepository.findAll( @@ -111,7 +112,7 @@ fun Application.userRoutes( .run { copy( name = request.username ?: name, - password = request.password?.hash() ?: password, + password = request.password?.let { passwordHasher.hash(it) } ?: password, email = request.email ?: email ) } @@ -143,7 +144,7 @@ fun Application.userRoutes( userRepository.findAll(nameOrEmail = request.username) .firstOrNull() ?.let { - val email = it.email ?: return@let + val email = it.email val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id)) emailService.sendPasswordResetEmail(passwordResetToken, email) } @@ -167,7 +168,7 @@ fun Application.userRoutes( userRepository.findAll(listOf(passwordResetToken.userId)) .firstOrNull() ?.let { - userRepository.save(it.copy(password = request.password.hash())) + userRepository.save(it.copy(password = passwordHasher.hash(request.password))) passwordResetRepository.delete(passwordResetToken) } ?: run { diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 413662c..7575e13 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(libs.kotlin.reflect) implementation(libs.bundles.ktor.server) implementation(libs.kotlinx.coroutines.core) + implementation(libs.bcrypt) implementation(libs.logback) implementation(libs.mail) testImplementation(project(":testhelpers")) diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index f42b12e..afad6a8 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -1,5 +1,6 @@ -package com.wbrawner.twigs.server +package com.wbrawner.twigs.server +import at.favre.lib.crypto.bcrypt.BCrypt import ch.qos.logback.classic.Level import com.wbrawner.twigs.* import com.wbrawner.twigs.db.* @@ -19,10 +20,7 @@ import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.plugins.cors.routing.* import io.ktor.server.response.* import io.ktor.server.sessions.* -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit @@ -46,10 +44,12 @@ fun Application.module() { "postgresql" -> { "jdbc:$dbType://$dbHost:$dbPort/$dbName?stringtype=unspecified" } + "sqlite" -> { Class.forName("org.sqlite.JDBC") "jdbc:$dbType:$dbName" } + else -> { throw RuntimeException("Unsupported DB type: $dbType") } @@ -60,6 +60,27 @@ fun Application.module() { username = dbUser password = dbPass }).also { + val metadataRepository = JdbcMetadataRepository(it) + val metadata = runBlocking { + val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata()) + var version = metadata.version + while (currentCoroutineContext().isActive && version++ < DATABASE_VERSION) { + metadataRepository.runMigration(version) + metadataRepository.save(metadata.copy(version = version)) + } + if (metadata.salt.isBlank()) { + metadataRepository.save( + metadata.copy( + salt = environment.config + .propertyOrNull("twigs.password.salt") + ?.getString() + ?: randomString(16) + ) + ) + } else { + metadata + } + } moduleWithDependencies( emailService = SmtpEmailService( from = environment.config.propertyOrNull("twigs.smtp.from")?.getString(), @@ -72,6 +93,9 @@ fun Application.module() { budgetRepository = JdbcBudgetRepository(it), categoryRepository = JdbcCategoryRepository(it), passwordResetRepository = JdbcPasswordResetRepository(it), + passwordHasher = { password -> + String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray())) + }, permissionRepository = JdbcPermissionRepository(it), recurringTransactionRepository = JdbcRecurringTransactionRepository(it), sessionRepository = JdbcSessionRepository(it), @@ -87,6 +111,7 @@ fun Application.moduleWithDependencies( budgetRepository: BudgetRepository, categoryRepository: CategoryRepository, passwordResetRepository: PasswordResetRepository, + passwordHasher: PasswordHasher, permissionRepository: PermissionRepository, recurringTransactionRepository: RecurringTransactionRepository, sessionRepository: SessionRepository, @@ -171,25 +196,9 @@ fun Application.moduleWithDependencies( categoryRoutes(categoryRepository, permissionRepository) recurringTransactionRoutes(recurringTransactionRepository, permissionRepository) transactionRoutes(transactionRepository, permissionRepository) - userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository) + userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository, passwordHasher) webRoutes() launch { - val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata()) - var version = metadata.version - while (currentCoroutineContext().isActive && version++ < DATABASE_VERSION) { - metadataRepository.runMigration(version) - metadataRepository.save(metadata.copy(version = version)) - } - salt = metadata.salt.ifEmpty { - metadataRepository.save( - metadata.copy( - salt = environment.config - .propertyOrNull("twigs.password.salt") - ?.getString() - ?: randomString(16) - ) - ).salt - } val jobs = listOf( SessionCleanupJob(sessionRepository), RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository) diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt index ff929b0..668d318 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt @@ -42,6 +42,7 @@ open class ApiTest { metadataRepository = metadataRepository, budgetRepository = budgetRepository, categoryRepository = categoryRepository, + passwordHasher = { it }, passwordResetRepository = passwordResetRepository, permissionRepository = permissionRepository, recurringTransactionRepository = recurringTransactionRepository, diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt index b08b0e6..4ba200a 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt @@ -1,6 +1,7 @@ package com.wbrawner.twigs.server.api import com.wbrawner.twigs.* +import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.User import io.ktor.client.call.* import io.ktor.client.request.* @@ -218,4 +219,75 @@ class UserRouteTest : ApiTest() { val response = client.get("/api/users") assertEquals(HttpStatusCode.Unauthorized, response.status) } + + @Test + fun `get users with empty query returns 400`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val response = client.get("/api/users?query=") { + header("Authorization", "Bearer ${session.token}") + } + assertEquals(HttpStatusCode.BadRequest, response.status) + val error: ErrorResponse = response.body() + assertEquals("query cannot be empty", error.message) + } + + @Test + fun `get users with valid query but no matches returns empty list`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val response = client.get("/api/users?query=something") { + header("Authorization", "Bearer ${session.token}") + } + assertEquals(HttpStatusCode.OK, response.status) + val userQueryResponse: List = response.body() + assertEquals(0, userQueryResponse.size) + } + + @Test + fun `get users with valid query and matches returns list`() = apiTest { client -> + val users = listOf( + User(name = "testuser", password = "testpassword"), + User(name = "otheruser", password = "otherpassword"), + ) + users.forEach { userRepository.save(it) } + val session = Session(userId = users.first().id) + sessionRepository.save(session) + val response = client.get("/api/users?query=user") { + header("Authorization", "Bearer ${session.token}") + } + assertEquals(HttpStatusCode.OK, response.status) + val userQueryResponse: List = response.body() + assertEquals(2, userQueryResponse.size) + repeat(2) { i -> + assertEquals(users[i].id, userQueryResponse[i].id, "User IDs at index $i don't match") + assertEquals(users[i].name, userQueryResponse[i].username, "Usernames at index $i don't match") + assertEquals(users[i].email, userQueryResponse[i].email, "User emails at index $i don't match") + } + } + + @Test + fun `get users with empty budgetId returns 400`() = apiTest { client -> + + } + +// @Test +// fun ``() = apiTest { client -> +// +// } + +// @Test +// fun ``() = apiTest { client -> +// +// } } \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts index a93ffc5..aba1aa8 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -6,7 +6,6 @@ plugins { dependencies { implementation(kotlin("stdlib")) api(libs.ktor.server.auth) - api(libs.bcrypt) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) } diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt index 7fdf1ef..e2d2105 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt @@ -1,6 +1,5 @@ package com.wbrawner.twigs -import at.favre.lib.crypto.bcrypt.BCrypt import com.wbrawner.twigs.model.Frequency import java.time.Instant import java.util.* @@ -51,9 +50,6 @@ fun randomString(length: Int = 32): String { return id.toString() } -lateinit var salt: String -fun String.hash(): String = String(BCrypt.withDefaults().hash(10, salt.toByteArray(), this.toByteArray())) - fun String.toInstant(): Instant = Instant.parse(this) fun String.asFrequency(): Frequency = Frequency.parse(this) diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt index 02fdbac..f9ef058 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt @@ -9,4 +9,8 @@ interface UserRepository : Repository { ): List fun findAll(nameLike: String): List +} + +fun interface PasswordHasher { + fun hash(password: String): String } \ No newline at end of file diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt index f3f47a1..9c8cfc3 100644 --- a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeRepository.kt @@ -4,7 +4,7 @@ import com.wbrawner.twigs.Identifiable import com.wbrawner.twigs.storage.Repository abstract class FakeRepository : Repository { - val entities = mutableListOf() + open val entities = mutableListOf() override suspend fun findAll(ids: List?): List = if (ids == null) { entities diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt index c14f842..86a5524 100644 --- a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakeUserRepository.kt @@ -4,6 +4,19 @@ import com.wbrawner.twigs.model.User import com.wbrawner.twigs.storage.UserRepository class FakeUserRepository : FakeRepository(), UserRepository { + override val entities: MutableList = mutableListOf( + User( + name = "testuser", + email = "test@example.com", + password = "\$2a\$10\$bETxbFPja1PyXVLybETxb.CWBYzyYdZpmCcA7NSIN8dkdzidt1Xv2" // testpass + ), + User( + name = "otheruser", + email = "other@example.com", + password = "\$2a\$10\$bETxbFPja1PyXVLybETxb..rhfIeOkP4qil1Drj29LDUhBxVkm6fS" + ), + ) + override fun findAll(nameOrEmail: String, password: String?): List { return entities.filter { user -> (user.name.equals(nameOrEmail, ignoreCase = true) || user.email.equals(