Implement password reset
Signed-off-by: Billy Brawner <me@wbrawner.com>
This commit is contained in:
parent
58c6508d0a
commit
909b667c62
20 changed files with 326 additions and 24 deletions
|
@ -10,7 +10,7 @@ dependencies {
|
||||||
implementation(project(":storage"))
|
implementation(project(":storage"))
|
||||||
api(libs.ktor.server.core)
|
api(libs.ktor.server.core)
|
||||||
api(libs.ktor.serialization)
|
api(libs.ktor.serialization)
|
||||||
api(libs.kotlin.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
testImplementation(libs.junit.jupiter.api)
|
testImplementation(libs.junit.jupiter.api)
|
||||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.PasswordResetToken
|
||||||
import com.wbrawner.twigs.model.Permission
|
import com.wbrawner.twigs.model.Permission
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.model.Session
|
||||||
import com.wbrawner.twigs.model.User
|
import com.wbrawner.twigs.model.User
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UserRequest(
|
data class UserRequest(
|
||||||
|
@ -31,13 +31,24 @@ data class UserPermissionResponse(val user: String, val permission: Permission?)
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SessionResponse(val userId: String, val token: String, val expiration: String)
|
data class SessionResponse(val userId: String, val token: String, val expiration: String)
|
||||||
|
|
||||||
data class PasswordResetRequest(
|
/**
|
||||||
val userId: Long,
|
* Used to request the password reset email
|
||||||
val id: String = randomString(),
|
*/
|
||||||
private val date: Calendar = GregorianCalendar(),
|
@Serializable
|
||||||
private val token: String = randomString()
|
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 User.asResponse(): UserResponse = UserResponse(id, name, email)
|
||||||
|
|
||||||
fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString())
|
fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString())
|
||||||
|
|
||||||
|
fun PasswordResetToken.asResponse(): PasswordResetTokenResponse =
|
||||||
|
PasswordResetTokenResponse(userId, id, expiration.toString())
|
|
@ -1,7 +1,9 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.PasswordResetToken
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.model.Session
|
||||||
import com.wbrawner.twigs.model.User
|
import com.wbrawner.twigs.model.User
|
||||||
|
import com.wbrawner.twigs.storage.PasswordResetRepository
|
||||||
import com.wbrawner.twigs.storage.PermissionRepository
|
import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
import com.wbrawner.twigs.storage.SessionRepository
|
import com.wbrawner.twigs.storage.SessionRepository
|
||||||
import com.wbrawner.twigs.storage.UserRepository
|
import com.wbrawner.twigs.storage.UserRepository
|
||||||
|
@ -11,8 +13,11 @@ import io.ktor.http.*
|
||||||
import io.ktor.request.*
|
import io.ktor.request.*
|
||||||
import io.ktor.response.*
|
import io.ktor.response.*
|
||||||
import io.ktor.routing.*
|
import io.ktor.routing.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
fun Application.userRoutes(
|
fun Application.userRoutes(
|
||||||
|
emailService: EmailService,
|
||||||
|
passwordResetRepository: PasswordResetRepository,
|
||||||
permissionRepository: PermissionRepository,
|
permissionRepository: PermissionRepository,
|
||||||
sessionRepository: SessionRepository,
|
sessionRepository: SessionRepository,
|
||||||
userRepository: UserRepository
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,8 +25,9 @@ dependencies {
|
||||||
implementation(libs.ktor.server.core)
|
implementation(libs.ktor.server.core)
|
||||||
implementation(libs.ktor.server.cio)
|
implementation(libs.ktor.server.cio)
|
||||||
implementation(libs.ktor.server.sessions)
|
implementation(libs.ktor.server.sessions)
|
||||||
implementation(libs.kotlin.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.logback)
|
implementation(libs.logback)
|
||||||
|
implementation(libs.mail)
|
||||||
testImplementation(project(":testhelpers"))
|
testImplementation(project(":testhelpers"))
|
||||||
testImplementation(libs.junit.jupiter.api)
|
testImplementation(libs.junit.jupiter.api)
|
||||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.wbrawner.twigs.server
|
package com.wbrawner.twigs.server
|
||||||
|
|
||||||
|
import ch.qos.logback.classic.Level
|
||||||
import com.wbrawner.twigs.*
|
import com.wbrawner.twigs.*
|
||||||
import com.wbrawner.twigs.db.*
|
import com.wbrawner.twigs.db.*
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.model.Session
|
||||||
|
@ -19,11 +20,12 @@ import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
fun main(args: Array<String>): Unit = io.ktor.server.cio.EngineMain.main(args)
|
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() {
|
fun Application.module() {
|
||||||
val dbHost = environment.config.propertyOrNull("twigs.database.host")?.getString() ?: "localhost"
|
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 dbUser = environment.config.propertyOrNull("twigs.database.user")?.getString() ?: "twigs"
|
||||||
val dbPass = environment.config.propertyOrNull("twigs.database.password")?.getString() ?: "twigs"
|
val dbPass = environment.config.propertyOrNull("twigs.database.password")?.getString() ?: "twigs"
|
||||||
val jdbcUrl = "jdbc:postgresql://$dbHost:$dbPort/$dbName?stringtype=unspecified"
|
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 {
|
HikariDataSource(HikariConfig().apply {
|
||||||
setJdbcUrl(jdbcUrl)
|
setJdbcUrl(jdbcUrl)
|
||||||
username = dbUser
|
username = dbUser
|
||||||
password = dbPass
|
password = dbPass
|
||||||
}).also {
|
}).also {
|
||||||
moduleWithDependencies(
|
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),
|
metadataRepository = MetadataRepository(it),
|
||||||
budgetRepository = JdbcBudgetRepository(it),
|
budgetRepository = JdbcBudgetRepository(it),
|
||||||
categoryRepository = JdbcCategoryRepository(it),
|
categoryRepository = JdbcCategoryRepository(it),
|
||||||
|
passwordResetRepository = JdbcPasswordResetRepository(it),
|
||||||
permissionRepository = JdbcPermissionRepository(it),
|
permissionRepository = JdbcPermissionRepository(it),
|
||||||
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
||||||
sessionRepository = JdbcSessionRepository(it),
|
sessionRepository = JdbcSessionRepository(it),
|
||||||
|
@ -51,9 +62,11 @@ fun Application.module() {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Application.moduleWithDependencies(
|
fun Application.moduleWithDependencies(
|
||||||
|
emailService: EmailService,
|
||||||
metadataRepository: MetadataRepository,
|
metadataRepository: MetadataRepository,
|
||||||
budgetRepository: BudgetRepository,
|
budgetRepository: BudgetRepository,
|
||||||
categoryRepository: CategoryRepository,
|
categoryRepository: CategoryRepository,
|
||||||
|
passwordResetRepository: PasswordResetRepository,
|
||||||
permissionRepository: PermissionRepository,
|
permissionRepository: PermissionRepository,
|
||||||
recurringTransactionRepository: RecurringTransactionRepository,
|
recurringTransactionRepository: RecurringTransactionRepository,
|
||||||
sessionRepository: SessionRepository,
|
sessionRepository: SessionRepository,
|
||||||
|
@ -137,7 +150,7 @@ fun Application.moduleWithDependencies(
|
||||||
categoryRoutes(categoryRepository, permissionRepository)
|
categoryRoutes(categoryRepository, permissionRepository)
|
||||||
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
|
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
|
||||||
transactionRoutes(transactionRepository, permissionRepository)
|
transactionRoutes(transactionRepository, permissionRepository)
|
||||||
userRoutes(permissionRepository, sessionRepository, userRepository)
|
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository)
|
||||||
webRoutes()
|
webRoutes()
|
||||||
launch {
|
launch {
|
||||||
val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata())
|
val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata())
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,7 +21,16 @@ twigs {
|
||||||
password = twigs
|
password = twigs
|
||||||
password = ${?TWIGS_DB_PASS}
|
password = ${?TWIGS_DB_PASS}
|
||||||
}
|
}
|
||||||
|
url = localhost
|
||||||
|
url = ${?TWIGS_HOST}
|
||||||
password {
|
password {
|
||||||
salt = ${?TWIGS_PW_SALT}
|
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}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
49
app/src/main/resources/email/html/passwordreset.html
Normal file
49
app/src/main/resources/email/html/passwordreset.html
Normal 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>
|
5
app/src/main/resources/email/plain/passwordreset.txt
Normal file
5
app/src/main/resources/email/plain/passwordreset.txt
Normal 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}
|
7
core/src/main/kotlin/com/wbrawner/twigs/EmailService.kt
Normal file
7
core/src/main/kotlin/com/wbrawner/twigs/EmailService.kt
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.PasswordResetToken
|
||||||
|
|
||||||
|
interface EmailService {
|
||||||
|
fun sendPasswordResetEmail(token: PasswordResetToken, to: String)
|
||||||
|
}
|
|
@ -35,6 +35,12 @@ val twoWeeksFromNow: Instant
|
||||||
toInstant()
|
toInstant()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val tomorrow: Instant
|
||||||
|
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||||
|
add(Calendar.DATE, 1)
|
||||||
|
toInstant()
|
||||||
|
}
|
||||||
|
|
||||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
|
||||||
fun randomString(length: Int = 32): String {
|
fun randomString(length: Int = 32): String {
|
||||||
|
|
|
@ -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
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -25,11 +25,15 @@ class JdbcUserRepository(dataSource: DataSource) : JdbcRepository<User, JdbcUser
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun findAll(nameOrEmail: String, password: String): List<User> = dataSource.connection.use { conn ->
|
override fun findAll(nameOrEmail: String, password: String?): List<User> = dataSource.connection.use { conn ->
|
||||||
conn.executeQuery(
|
var sql =
|
||||||
"SELECT * FROM $tableName WHERE (${Fields.USERNAME.name.lowercase()} = ? OR ${Fields.EMAIL.name.lowercase()} = ?) AND ${Fields.PASSWORD.name.lowercase()} = ?",
|
"SELECT * FROM $tableName WHERE (${Fields.USERNAME.name.lowercase()} = ? OR ${Fields.EMAIL.name.lowercase()} = ?)"
|
||||||
listOf(nameOrEmail, nameOrEmail, password)
|
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?) {
|
enum class Fields(val entityField: (User) -> Any?) {
|
||||||
|
|
25
db/src/main/resources/sql/3.sql
Normal file
25
db/src/main/resources/sql/3.sql
Normal 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
|
||||||
|
);
|
|
@ -3,9 +3,10 @@ bcrypt = "0.9.0"
|
||||||
hikari = "5.0.1"
|
hikari = "5.0.1"
|
||||||
junit = "5.8.2"
|
junit = "5.8.2"
|
||||||
kotlin = "1.6.21"
|
kotlin = "1.6.21"
|
||||||
kotlin-coroutines = "1.6.1"
|
kotlinx-coroutines = "1.6.2"
|
||||||
ktor = "1.6.6"
|
ktor = "1.6.6"
|
||||||
logback = "1.2.11"
|
logback = "1.2.11"
|
||||||
|
mail = "1.6.2"
|
||||||
postgres = "42.3.4"
|
postgres = "42.3.4"
|
||||||
shadow = "7.0.0"
|
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" }
|
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
|
||||||
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
|
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
|
||||||
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" }
|
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" }
|
||||||
kotlin-coroutines-core = { module = "org.jetbrains.kotlin:kotlin-coroutines-core", version.ref = "kotlin-coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlin-coroutines-test = { module = "org.jetbrains.kotlin:kotlin-coroutines-test", version.ref = "kotlin-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-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||||
ktor-auth = { module = "io.ktor:ktor-auth", version.ref = "ktor" }
|
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-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||||
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", 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" }
|
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" }
|
postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|
|
@ -6,7 +6,7 @@ plugins {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
api(project(":core"))
|
api(project(":core"))
|
||||||
api(libs.kotlin.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
testImplementation(libs.junit.jupiter.api)
|
testImplementation(libs.junit.jupiter.api)
|
||||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.wbrawner.twigs.storage
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.PasswordResetToken
|
||||||
|
|
||||||
|
interface PasswordResetRepository : Repository<PasswordResetToken> {}
|
|
@ -5,7 +5,7 @@ import com.wbrawner.twigs.model.User
|
||||||
interface UserRepository : Repository<User> {
|
interface UserRepository : Repository<User> {
|
||||||
fun findAll(
|
fun findAll(
|
||||||
nameOrEmail: String,
|
nameOrEmail: String,
|
||||||
password: String
|
password: String? = null
|
||||||
): List<User>
|
): List<User>
|
||||||
|
|
||||||
fun findAll(nameLike: String): List<User>
|
fun findAll(nameLike: String): List<User>
|
||||||
|
|
|
@ -6,7 +6,7 @@ plugins {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
implementation(project(":storage"))
|
implementation(project(":storage"))
|
||||||
api(libs.kotlin.coroutines.test)
|
api(libs.kotlinx.coroutines.test)
|
||||||
api(libs.junit.jupiter.api)
|
api(libs.junit.jupiter.api)
|
||||||
runtimeOnly(libs.junit.jupiter.engine)
|
runtimeOnly(libs.junit.jupiter.engine)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue