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"))
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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 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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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_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}
|
||||
}
|
||||
}
|
||||
|
|
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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 ->
|
||||
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?) {
|
||||
|
|
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,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]
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
fun findAll(
|
||||
nameOrEmail: String,
|
||||
password: String
|
||||
password: String? = null
|
||||
): List<User>
|
||||
|
||||
fun findAll(nameLike: String): List<User>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue