Implement password reset

Signed-off-by: Billy Brawner <me@wbrawner.com>
This commit is contained in:
William Brawner 2022-06-03 22:46:02 -06:00
parent 58c6508d0a
commit 909b667c62
20 changed files with 326 additions and 24 deletions

View file

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

View file

@ -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())
fun PasswordResetToken.asResponse(): PasswordResetTokenResponse =
PasswordResetTokenResponse(userId, id, expiration.toString())

View file

@ -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<ResetPasswordRequest>()
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<PasswordResetRequest>()
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)
}
}
}
}

View file

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

View file

@ -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<String>): 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())

View file

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

View file

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

View file

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html>
<head>
<title>Twigs - Reset Your Password</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"
"/>
</head>
<body>
<style>
html, body {
font-family: sans-serif;
height: 100%;
height: 100vh;
background: white;
}
a {
color: #30d158;
}
</style>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<img src="https://twigs.wbrawner.com/favicon-96x96.png"/>
</td>
</tr>
<tr>
<td align="center">
<h1>Reset Your Password</h1>
</td>
</tr>
<tr>
<td align="center">
If you requested a password reset, please <a href="{reset_url}" target="_blank">click here</a> 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:
</td>
</tr>
<tr>
<td align="center">
<pre>{reset_url}</pre>
</td>
</tr>
</table>
</body>
</html>

View file

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

View file

@ -0,0 +1,7 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.PasswordResetToken
interface EmailService {
fun sendPasswordResetEmail(token: PasswordResetToken, to: String)
}

View file

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

View file

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

View file

@ -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<PasswordResetToken, JdbcPasswordResetRepository.Fields>(dataSource),
PasswordResetRepository {
override val tableName: String = TABLE_USER
override val fields: Map<Fields, (PasswordResetToken) -> Any?> = Fields.values().associateWith { it.entityField }
override val conflictFields: Collection<String> = 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"
}
}

View file

@ -25,11 +25,15 @@ class JdbcUserRepository(dataSource: DataSource) : JdbcRepository<User, JdbcUser
)
}
override fun findAll(nameOrEmail: String, password: String): List<User> = 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<User> = 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?) {

View file

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

View file

@ -3,9 +3,10 @@ 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"
@ -14,8 +15,8 @@ bcrypt = { module = "at.favre.lib:bcrypt", version.ref = "bcrypt" }
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]

View file

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

View file

@ -0,0 +1,5 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.PasswordResetToken
interface PasswordResetRepository : Repository<PasswordResetToken> {}

View file

@ -5,7 +5,7 @@ import com.wbrawner.twigs.model.User
interface UserRepository : Repository<User> {
fun findAll(
nameOrEmail: String,
password: String
password: String? = null
): List<User>
fun findAll(nameLike: String): List<User>

View file

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