diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 2afc7b2..b60f2b6 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -10,7 +10,7 @@ dependencies { implementation(project(":storage")) api(libs.ktor.server.core) api(libs.ktor.serialization) - api(libs.kotlin.coroutines.core) + api(libs.kotlinx.coroutines.core) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) } diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt index 872f272..f8e9f91 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt @@ -1,10 +1,10 @@ package com.wbrawner.twigs +import com.wbrawner.twigs.model.PasswordResetToken import com.wbrawner.twigs.model.Permission import com.wbrawner.twigs.model.Session import com.wbrawner.twigs.model.User import kotlinx.serialization.Serializable -import java.util.* @Serializable data class UserRequest( @@ -31,13 +31,24 @@ data class UserPermissionResponse(val user: String, val permission: Permission?) @Serializable data class SessionResponse(val userId: String, val token: String, val expiration: String) -data class PasswordResetRequest( - val userId: Long, - val id: String = randomString(), - private val date: Calendar = GregorianCalendar(), - private val token: String = randomString() -) +/** + * Used to request the password reset email + */ +@Serializable +data class ResetPasswordRequest(val username: String) + +/** + * Used to modify the user's password after receiving the password reset email + */ +@Serializable +data class PasswordResetRequest(val token: String, val password: String) + +@Serializable +data class PasswordResetTokenResponse(val userId: String, val id: String, val expiration: String) fun User.asResponse(): UserResponse = UserResponse(id, name, email) -fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString()) \ No newline at end of file +fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString()) + +fun PasswordResetToken.asResponse(): PasswordResetTokenResponse = + PasswordResetTokenResponse(userId, id, expiration.toString()) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt index 9e53aa9..c6b300e 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt @@ -1,7 +1,9 @@ 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 @@ -11,8 +13,11 @@ import io.ktor.http.* import io.ktor.request.* import io.ktor.response.* import io.ktor.routing.* +import java.time.Instant fun Application.userRoutes( + emailService: EmailService, + passwordResetRepository: PasswordResetRepository, permissionRepository: PermissionRepository, sessionRepository: SessionRepository, userRepository: UserRepository @@ -140,5 +145,46 @@ fun Application.userRoutes( } } } + + route("/api/resetpassword") { + post { + val request = call.receive() + userRepository.findAll(nameOrEmail = request.username) + .firstOrNull() + ?.let { + val email = it.email ?: return@let + val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id)) + emailService.sendPasswordResetEmail(passwordResetToken, email) + } + call.respond(HttpStatusCode.Accepted) + } + } + + route("/api/passwordreset") { + post { + val request = call.receive() + val passwordResetToken = passwordResetRepository.findAll(listOf(request.token)) + .firstOrNull() + ?: run { + errorResponse(HttpStatusCode.Unauthorized, "Invalid token") + return@post + } + if (passwordResetToken.expiration.isBefore(Instant.now())) { + errorResponse(HttpStatusCode.Unauthorized, "Token expired") + return@post + } + userRepository.findAll(listOf(passwordResetToken.userId)) + .firstOrNull() + ?.let { + userRepository.save(it.copy(password = request.password.hash())) + passwordResetRepository.delete(passwordResetToken) + } + ?: run { + errorResponse(HttpStatusCode.InternalServerError, "Invalid token") + return@post + } + call.respond(HttpStatusCode.NoContent) + } + } } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3408ee9..90b5281 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -25,8 +25,9 @@ dependencies { implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) implementation(libs.ktor.server.sessions) - implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlinx.coroutines.core) implementation(libs.logback) + implementation(libs.mail) testImplementation(project(":testhelpers")) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) 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 b44ba96..7c12465 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 +import ch.qos.logback.classic.Level import com.wbrawner.twigs.* import com.wbrawner.twigs.db.* import com.wbrawner.twigs.model.Session @@ -19,11 +20,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit fun main(args: Array): Unit = io.ktor.server.cio.EngineMain.main(args) -private const val DATABASE_VERSION = 2 +private const val DATABASE_VERSION = 3 fun Application.module() { val dbHost = environment.config.propertyOrNull("twigs.database.host")?.getString() ?: "localhost" @@ -32,15 +34,24 @@ fun Application.module() { val dbUser = environment.config.propertyOrNull("twigs.database.user")?.getString() ?: "twigs" val dbPass = environment.config.propertyOrNull("twigs.database.password")?.getString() ?: "twigs" val jdbcUrl = "jdbc:postgresql://$dbHost:$dbPort/$dbName?stringtype=unspecified" + (LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger).level = Level.ERROR HikariDataSource(HikariConfig().apply { setJdbcUrl(jdbcUrl) username = dbUser password = dbPass }).also { moduleWithDependencies( + emailService = SmtpEmailService( + from = environment.config.propertyOrNull("twigs.smtp.from")?.getString(), + host = environment.config.propertyOrNull("twigs.smtp.host")?.getString(), + port = environment.config.propertyOrNull("twigs.smtp.port")?.getString()?.toIntOrNull(), + username = environment.config.propertyOrNull("twigs.smtp.user")?.getString(), + password = environment.config.propertyOrNull("twigs.smtp.pass")?.getString(), + ), metadataRepository = MetadataRepository(it), budgetRepository = JdbcBudgetRepository(it), categoryRepository = JdbcCategoryRepository(it), + passwordResetRepository = JdbcPasswordResetRepository(it), permissionRepository = JdbcPermissionRepository(it), recurringTransactionRepository = JdbcRecurringTransactionRepository(it), sessionRepository = JdbcSessionRepository(it), @@ -51,9 +62,11 @@ fun Application.module() { } fun Application.moduleWithDependencies( + emailService: EmailService, metadataRepository: MetadataRepository, budgetRepository: BudgetRepository, categoryRepository: CategoryRepository, + passwordResetRepository: PasswordResetRepository, permissionRepository: PermissionRepository, recurringTransactionRepository: RecurringTransactionRepository, sessionRepository: SessionRepository, @@ -137,7 +150,7 @@ fun Application.moduleWithDependencies( categoryRoutes(categoryRepository, permissionRepository) recurringTransactionRoutes(recurringTransactionRepository, permissionRepository) transactionRoutes(transactionRepository, permissionRepository) - userRoutes(permissionRepository, sessionRepository, userRepository) + userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository) webRoutes() launch { val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata()) diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/SmtpEmailService.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/SmtpEmailService.kt new file mode 100644 index 0000000..571a74c --- /dev/null +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/SmtpEmailService.kt @@ -0,0 +1,77 @@ +package com.wbrawner.twigs.server + +import com.wbrawner.twigs.EmailService +import com.wbrawner.twigs.model.PasswordResetToken +import java.util.* +import javax.mail.* +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeBodyPart +import javax.mail.internet.MimeMessage +import javax.mail.internet.MimeMultipart + + +class SmtpEmailService( + val from: String?, + val host: String?, + val port: Int?, + val username: String?, + val password: String? +) : EmailService { + private val canSendEmail = !from.isNullOrBlank() + && !host.isNullOrBlank() + && port != null + && !username.isNullOrBlank() + && !password.isNullOrBlank() + + private val session = Session.getInstance( + Properties().apply { + put("mail.smtp.auth", "true") + put("mail.smtp.host", host ?: "") + put("mail.smtp.port", port ?: 25) + put("mail.smtp.from", from ?: "") + }, + object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(username, password) + } + }) + + override fun sendPasswordResetEmail(token: PasswordResetToken, to: String) { + val resetUrl = "twigs://resetpassword?token=${token.id}" + val plainText = javaClass.getResource("/email/plain/passwordreset.txt") + ?.readText() + ?.replace("{reset_url}", resetUrl) + val html = javaClass.getResource("/email/html/passwordreset.html") + ?.readText() + ?.replace("{reset_url}", resetUrl) + sendEmail( + plainText, + html, + to, + "Twigs Password Reset" // TODO: Localization + ) + } + + private fun sendEmail(plainText: String?, html: String?, to: String, subject: String) { + if (!canSendEmail) return + if (plainText.isNullOrBlank() && html.isNullOrBlank()) return + val message = MimeMessage(session) + message.setFrom(InternetAddress(from, "Twigs")) + message.setRecipients(Message.RecipientType.TO, to) + val multipart: Multipart = MimeMultipart("alternative").apply { + plainText?.let { + addBodyPart(it.asMimeBodyPart("text/plain; charset=utf-8")) + } + html?.let { + addBodyPart(it.asMimeBodyPart("text/html; charset=utf-8")) + } + } + message.setContent(multipart) + message.subject = subject + Transport.send(message) + } + + private fun String.asMimeBodyPart(mimeType: String): MimeBodyPart = MimeBodyPart().apply { + setContent(this@asMimeBodyPart, mimeType) + } +} \ No newline at end of file diff --git a/app/src/main/resources/application.conf b/app/src/main/resources/application.conf index 9007c0f..ca1498b 100644 --- a/app/src/main/resources/application.conf +++ b/app/src/main/resources/application.conf @@ -21,7 +21,16 @@ twigs { password = twigs password = ${?TWIGS_DB_PASS} } + url = localhost + url = ${?TWIGS_HOST} password { salt = ${?TWIGS_PW_SALT} } + smtp { + from = ${?TWIGS_SMTP_FROM} + host = ${?TWIGS_SMTP_HOST} + port = ${?TWIGS_SMTP_PORT} + user = ${?TWIGS_SMTP_USER} + pass = ${?TWIGS_SMTP_PASS} + } } diff --git a/app/src/main/resources/email/html/passwordreset.html b/app/src/main/resources/email/html/passwordreset.html new file mode 100644 index 0000000..7adcb42 --- /dev/null +++ b/app/src/main/resources/email/html/passwordreset.html @@ -0,0 +1,49 @@ + + + + Twigs - Reset Your Password + + + + + + + + + + + + + + + + + +
+ +
+

Reset Your Password

+
+ If you requested a password reset, please click here to complete + the + process. If + you didn't make this request, + you can ignore this email. Alternatively, you can copy and paste the link below in your browser: +
+
{reset_url}
+
+ + + \ No newline at end of file diff --git a/app/src/main/resources/email/plain/passwordreset.txt b/app/src/main/resources/email/plain/passwordreset.txt new file mode 100644 index 0000000..e428272 --- /dev/null +++ b/app/src/main/resources/email/plain/passwordreset.txt @@ -0,0 +1,5 @@ +Reset Your Password + +If you requested a password reset, please open the link below to complete the process. If you didn't make this request, you can ignore this email. + +{reset_url} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wbrawner/twigs/EmailService.kt b/core/src/main/kotlin/com/wbrawner/twigs/EmailService.kt new file mode 100644 index 0000000..6fd707f --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/EmailService.kt @@ -0,0 +1,7 @@ +package com.wbrawner.twigs + +import com.wbrawner.twigs.model.PasswordResetToken + +interface EmailService { + fun sendPasswordResetEmail(token: PasswordResetToken, to: String) +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt index 866db44..7fdf1ef 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt @@ -35,6 +35,12 @@ val twoWeeksFromNow: Instant toInstant() } +val tomorrow: Instant + get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run { + add(Calendar.DATE, 1) + toInstant() + } + private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" fun randomString(length: Int = 32): String { diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/PasswordResetToken.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/PasswordResetToken.kt new file mode 100644 index 0000000..c624f7e --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/PasswordResetToken.kt @@ -0,0 +1,12 @@ +package com.wbrawner.twigs.model + +import com.wbrawner.twigs.Identifiable +import com.wbrawner.twigs.randomString +import com.wbrawner.twigs.tomorrow +import java.time.Instant + +data class PasswordResetToken( + override val id: String = randomString(), + val userId: String = "", + var expiration: Instant = tomorrow +) : Identifiable diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPasswordResetRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPasswordResetRepository.kt new file mode 100644 index 0000000..74d021e --- /dev/null +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPasswordResetRepository.kt @@ -0,0 +1,30 @@ +package com.wbrawner.twigs.db + +import com.wbrawner.twigs.model.PasswordResetToken +import com.wbrawner.twigs.storage.PasswordResetRepository +import java.sql.ResultSet +import javax.sql.DataSource + +class JdbcPasswordResetRepository(dataSource: DataSource) : + JdbcRepository(dataSource), + PasswordResetRepository { + override val tableName: String = TABLE_USER + override val fields: Map Any?> = Fields.values().associateWith { it.entityField } + override val conflictFields: Collection = listOf(ID) + + override fun ResultSet.toEntity(): PasswordResetToken = PasswordResetToken( + id = getString(ID), + userId = getString(Fields.USER_ID.name.lowercase()), + expiration = getInstant(Fields.EXPIRATION.name.lowercase())!! + ) + + enum class Fields(val entityField: (PasswordResetToken) -> Any?) { + USER_ID({ it.userId }), + EXPIRATION({ it.expiration }) + } + + companion object { + const val TABLE_USER = "password_reset_tokens" + } +} + diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt index 7e3fb41..ec0a2a8 100644 --- a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcUserRepository.kt @@ -25,11 +25,15 @@ class JdbcUserRepository(dataSource: DataSource) : JdbcRepository = dataSource.connection.use { conn -> - conn.executeQuery( - "SELECT * FROM $tableName WHERE (${Fields.USERNAME.name.lowercase()} = ? OR ${Fields.EMAIL.name.lowercase()} = ?) AND ${Fields.PASSWORD.name.lowercase()} = ?", - listOf(nameOrEmail, nameOrEmail, password) - ) + override fun findAll(nameOrEmail: String, password: String?): List = dataSource.connection.use { conn -> + var sql = + "SELECT * FROM $tableName WHERE (${Fields.USERNAME.name.lowercase()} = ? OR ${Fields.EMAIL.name.lowercase()} = ?)" + val params = mutableListOf(nameOrEmail, nameOrEmail) + password?.let { + sql += " AND ${Fields.PASSWORD.name.lowercase()} = ?" + params.add(it) + } + conn.executeQuery(sql, params) } enum class Fields(val entityField: (User) -> Any?) { diff --git a/db/src/main/resources/sql/3.sql b/db/src/main/resources/sql/3.sql new file mode 100644 index 0000000..f2f92dd --- /dev/null +++ b/db/src/main/resources/sql/3.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS password_reset_tokens +( + id + TEXT + PRIMARY + KEY, + user_id + TEXT + NOT + NULL, + expiration + TIMESTAMP + NOT + NULL, + CONSTRAINT + fk_users + FOREIGN + KEY +( + user_id +) REFERENCES users +( + id +) ON DELETE CASCADE + ); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28384ce..4a29a41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,19 +3,20 @@ bcrypt = "0.9.0" hikari = "5.0.1" junit = "5.8.2" kotlin = "1.6.21" -kotlin-coroutines = "1.6.1" +kotlinx-coroutines = "1.6.2" ktor = "1.6.6" logback = "1.2.11" +mail = "1.6.2" postgres = "42.3.4" shadow = "7.0.0" [libraries] bcrypt = { module = "at.favre.lib:bcrypt", version.ref = "bcrypt" } -hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } +hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } -kotlin-coroutines-core = { module = "org.jetbrains.kotlin:kotlin-coroutines-core", version.ref = "kotlin-coroutines" } -kotlin-coroutines-test = { module = "org.jetbrains.kotlin:kotlin-coroutines-test", version.ref = "kotlin-coroutines" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } ktor-auth = { module = "io.ktor:ktor-auth", version.ref = "ktor" } @@ -24,6 +25,7 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" } ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } +mail = { module = "com.sun.mail:javax.mail", version.ref = "mail" } postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" } [plugins] diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 69bb5d7..68efd6c 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -6,7 +6,7 @@ plugins { dependencies { implementation(kotlin("stdlib")) api(project(":core")) - api(libs.kotlin.coroutines.core) + api(libs.kotlinx.coroutines.core) testImplementation(libs.junit.jupiter.api) testRuntimeOnly(libs.junit.jupiter.engine) } diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/PasswordResetRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/PasswordResetRepository.kt new file mode 100644 index 0000000..a8da9d3 --- /dev/null +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/PasswordResetRepository.kt @@ -0,0 +1,5 @@ +package com.wbrawner.twigs.storage + +import com.wbrawner.twigs.model.PasswordResetToken + +interface PasswordResetRepository : Repository {} \ No newline at end of file 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 08e71f9..02fdbac 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/UserRepository.kt @@ -5,7 +5,7 @@ import com.wbrawner.twigs.model.User interface UserRepository : Repository { fun findAll( nameOrEmail: String, - password: String + password: String? = null ): List fun findAll(nameLike: String): List diff --git a/testhelpers/build.gradle.kts b/testhelpers/build.gradle.kts index 0dd86f2..c198366 100644 --- a/testhelpers/build.gradle.kts +++ b/testhelpers/build.gradle.kts @@ -6,7 +6,7 @@ plugins { dependencies { implementation(kotlin("stdlib")) implementation(project(":storage")) - api(libs.kotlin.coroutines.test) + api(libs.kotlinx.coroutines.test) api(libs.junit.jupiter.api) runtimeOnly(libs.junit.jupiter.engine) }