Skip to content
This repository has been archived by the owner on Feb 19, 2025. It is now read-only.

feat: password reset (BDE-42) #72

Merged
merged 2 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ import com.suitebde.models.application.Email
import com.suitebde.models.associations.Association
import com.suitebde.models.associations.CreateAssociationPayload
import com.suitebde.models.auth.*
import com.suitebde.models.users.ResetInUser
import com.suitebde.models.users.UpdateUserPayload
import com.suitebde.models.users.User
import com.suitebde.usecases.associations.*
import com.suitebde.usecases.auth.*
import com.suitebde.usecases.users.IGetUserUseCase
import com.suitebde.usecases.users.IUpdateUserLastLoginUseCase
import dev.kaccelero.commons.emails.ISendEmailUseCase
import dev.kaccelero.commons.exceptions.ControllerException
import dev.kaccelero.commons.localization.IGetLocaleForCallUseCase
import dev.kaccelero.commons.localization.ITranslateUseCase
import dev.kaccelero.commons.repositories.ICreateModelSuspendUseCase
import dev.kaccelero.commons.repositories.IGetModelSuspendUseCase
import dev.kaccelero.commons.repositories.IUpdateChildModelSuspendUseCase
import dev.kaccelero.commons.responses.RedirectResponse
import dev.kaccelero.commons.users.IRequireUserForCallUseCase
import dev.kaccelero.models.UUID
Expand All @@ -37,11 +41,16 @@ class AuthController(
private val createAuthCodeUseCase: ICreateAuthCodeUseCase,
private val deleteAuthCodeUseCase: IDeleteAuthCodeUseCase,
private val generateAuthTokenUseCase: IGenerateAuthTokenUseCase,
private val getUserUseCase: IGetUserUseCase,
private val updateUserUseCase: IUpdateChildModelSuspendUseCase<User, UUID, UpdateUserPayload, UUID>,
private val updateUserLastLoginUseCase: IUpdateUserLastLoginUseCase,
private val createCodeInEmailUseCase: ICreateCodeInEmailUseCase,
private val getCodeInEmailUseCase: IGetCodeInEmailUseCase,
private val deleteCodeInEmailUseCase: IDeleteCodeInEmailUseCase,
private val createAssociationUseCase: ICreateModelSuspendUseCase<Association, CreateAssociationPayload>,
private val createResetPasswordUseCase: ICreateResetPasswordUseCase,
private val getResetPasswordUseCase: IGetResetPasswordUseCase,
private val deleteResetPasswordUseCase: IDeleteResetPasswordUseCase,
private val sendEmailUseCase: ISendEmailUseCase,
private val getLocaleForCallUseCase: IGetLocaleForCallUseCase,
private val translateUseCase: ITranslateUseCase,
Expand Down Expand Up @@ -83,8 +92,8 @@ class AuthController(
associationId: UUID?,
): Map<String, Any> {
val association = register(call, associationId)
val code = createCodeInEmailUseCase(payload.email, association.id) ?: throw ControllerException(
HttpStatusCode.BadRequest, "auth_register_email_taken"
val code = createCodeInEmailUseCase(payload.email, association.id) ?: return mapOf(
"error" to "auth_register_email_taken"
)
val locale = getLocaleForCallUseCase(call)
sendEmailUseCase(
Expand Down Expand Up @@ -182,6 +191,53 @@ class AuthController(
return mapOf("success" to "auth_join_submitted")
}

override suspend fun reset() {}

override suspend fun reset(call: ApplicationCall, payload: RegisterPayload): Map<String, Any> {
val code = createResetPasswordUseCase(payload.email) ?: return mapOf(
"error" to "auth_reset_email_unknown"
)
val locale = getLocaleForCallUseCase(call)
sendEmailUseCase(
Email(
translateUseCase(locale, "auth_reset_email_title"),
translateUseCase(locale, "auth_reset_email_body", listOf(code.code))
),
listOf(payload.email)
)
return mapOf("success" to "auth_reset_email_sent")
}

override suspend fun resetCode(call: ApplicationCall, code: String): ResetInUser {
return getResetPasswordUseCase(code) ?: throw ControllerException(
HttpStatusCode.NotFound, "auth_code_invalid"
)
}

override suspend fun resetCode(
call: ApplicationCall,
code: String,
payload: ResetPasswordPayload,
): RedirectResponse {
val resetCode = getResetPasswordUseCase(code) ?: throw ControllerException(
HttpStatusCode.NotFound, "auth_code_invalid"
)
val user = getUserUseCase(resetCode.userId) ?: throw ControllerException(
HttpStatusCode.InternalServerError, "error_internal"
)
updateUserUseCase(user.id, UpdateUserPayload(password = payload.password), user.associationId)
deleteResetPasswordUseCase(code)
val locale = getLocaleForCallUseCase(call)
sendEmailUseCase(
Email(
translateUseCase(locale, "auth_reset_email_done_title"),
translateUseCase(locale, "auth_reset_email_done_body")
),
listOf(user.email)
)
return RedirectResponse("/auth/login")
}

override suspend fun token(payload: AuthRequest): AuthToken {
val client = getAuthCodeUseCase(payload.code)?.takeIf {
it.client.id == payload.clientId && it.client.clientSecret == payload.clientSecret
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.suitebde.controllers.auth

import com.suitebde.models.associations.Association
import com.suitebde.models.auth.*
import com.suitebde.models.users.ResetInUser
import dev.kaccelero.annotations.*
import dev.kaccelero.commons.responses.RedirectResponse
import dev.kaccelero.controllers.IUnitController
Expand Down Expand Up @@ -86,6 +87,26 @@ interface IAuthController : IUnitController {
@Path("POST", "/join/{code}")
suspend fun joinCode(@PathParameter code: String, @Payload payload: JoinCodePayload): Map<String, Any>

@TemplateMapping("auth/reset.ftl")
@Path("GET", "/reset")
suspend fun reset()

@TemplateMapping("auth/reset.ftl")
@Path("POST", "/reset")
suspend fun reset(call: ApplicationCall, @Payload payload: RegisterPayload): Map<String, Any>

@TemplateMapping("auth/reset.ftl")
@Path("GET", "/reset/{code}")
suspend fun resetCode(call: ApplicationCall, @PathParameter code: String): ResetInUser

@TemplateMapping("auth/reset.ftl")
@Path("POST", "/reset/{code}")
suspend fun resetCode(
call: ApplicationCall,
@PathParameter code: String,
@Payload payload: ResetPasswordPayload,
): RedirectResponse

@APIMapping("createToken")
@Path("POST", "/token")
@DocumentedTag("Auth")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.suitebde.database.users

import com.suitebde.extensions.generateId
import com.suitebde.models.users.ResetInUser
import dev.kaccelero.models.UUID
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
import org.jetbrains.exposed.sql.selectAll

object ResetsInUsers : Table() {

val code = varchar("code", 32)
val userId = uuid("user_id")
val expiration = timestamp("expiration")

override val primaryKey = PrimaryKey(code)

fun generateCode(): String {
val candidate = String.generateId()
return if (selectAll().where { code eq candidate }.count() > 0) generateCode() else candidate
}

fun toResetInUser(
row: ResultRow,
) = ResetInUser(
row[code],
UUID(row[userId]),
row[expiration]
)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.suitebde.database.users

import com.suitebde.models.users.ResetInUser
import com.suitebde.repositories.users.IResetsInUsersRepository
import dev.kaccelero.database.IDatabase
import dev.kaccelero.database.set
import dev.kaccelero.models.UUID
import kotlinx.datetime.Instant
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll

class ResetsInUsersDatabaseRepository(
private val database: IDatabase,
) : IResetsInUsersRepository {

init {
database.transaction {
SchemaUtils.create(ResetsInUsers)
}
}

override suspend fun create(userId: UUID, expiration: Instant): ResetInUser? =
database.suspendedTransaction {
ResetsInUsers.insert {
it[code] = generateCode()
it[ResetsInUsers.userId] = userId
it[ResetsInUsers.expiration] = expiration
}.resultedValues?.map(ResetsInUsers::toResetInUser)?.singleOrNull()
}

override suspend fun get(code: String): ResetInUser? =
database.suspendedTransaction {
ResetsInUsers
.selectAll()
.where { ResetsInUsers.code eq code }
.map(ResetsInUsers::toResetInUser)
.singleOrNull()
}

override suspend fun delete(code: String): Boolean =
database.suspendedTransaction {
ResetsInUsers.deleteWhere {
ResetsInUsers.code eq code
}
} == 1

override suspend fun getExpiringBefore(date: Instant): List<ResetInUser> =
database.suspendedTransaction {
ResetsInUsers
.selectAll()
.where { ResetsInUsers.expiration less date }
.map(ResetsInUsers::toResetInUser)
}

}
12 changes: 12 additions & 0 deletions backend/src/commonMain/kotlin/com/suitebde/plugins/Koin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import com.suitebde.database.scans.ScansDatabaseRepository
import com.suitebde.database.stripe.StripeAccountsDatabaseRepository
import com.suitebde.database.stripe.StripeOrdersDatabaseRepository
import com.suitebde.database.users.ClientsInUsersDatabaseRepository
import com.suitebde.database.users.ResetsInUsersDatabaseRepository
import com.suitebde.database.users.SubscriptionsInUsersDatabaseRepository
import com.suitebde.database.users.UsersDatabaseRepository
import com.suitebde.database.web.WebMenusDatabaseRepository
Expand Down Expand Up @@ -79,6 +80,7 @@ import com.suitebde.repositories.scans.IScansRepository
import com.suitebde.repositories.stripe.IStripeAccountsRepository
import com.suitebde.repositories.stripe.IStripeOrdersRepository
import com.suitebde.repositories.users.IClientsInUsersRepository
import com.suitebde.repositories.users.IResetsInUsersRepository
import com.suitebde.repositories.users.ISubscriptionsInUsersRepository
import com.suitebde.repositories.users.IUsersRepository
import com.suitebde.repositories.web.IWebMenusRepository
Expand Down Expand Up @@ -201,6 +203,7 @@ fun Application.configureKoin() {
// Users
single<IUsersRepository> { UsersDatabaseRepository(get()) }
single<IClientsInUsersRepository> { ClientsInUsersDatabaseRepository(get()) }
single<IResetsInUsersRepository> { ResetsInUsersDatabaseRepository(get()) }
single<ISubscriptionsInUsersRepository> { SubscriptionsInUsersDatabaseRepository(get()) }

// Scans
Expand Down Expand Up @@ -231,6 +234,7 @@ fun Application.configureKoin() {
single<ISendEmailUseCase> { SendEmailUseCase(get()) }
single<IExpireUseCase> {
ExpireUseCase(
get(), get(),
get(), get(),
get(), get(),
get(), get(named<User>()),
Expand Down Expand Up @@ -408,6 +412,9 @@ fun Application.configureKoin() {
single<IGetClientForUserForRefreshTokenUseCase> {
GetClientForUserForRefreshTokenUseCase(get(), get(), get(named<Client>()))
}
single<ICreateResetPasswordUseCase> { CreateResetPasswordUseCase(get(), get()) }
single<IGetResetPasswordUseCase> { GetResetPasswordUseCase(get()) }
single<IDeleteResetPasswordUseCase> { DeleteResetPasswordUseCase(get()) }

// Users
single<IGetUserUseCase> { GetUserUseCase(get()) }
Expand Down Expand Up @@ -750,12 +757,17 @@ fun Application.configureKoin() {
get(),
get(),
get(),
get(named<User>()),
get(),
get(),
get(),
get(),
get(named<Association>()),
get(),
get(),
get(),
get(),
get(),
get()
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.suitebde.repositories.users

import com.suitebde.models.users.ResetInUser
import dev.kaccelero.models.UUID
import kotlinx.datetime.Instant

interface IResetsInUsersRepository {

suspend fun create(userId: UUID, expiration: Instant): ResetInUser?
suspend fun get(code: String): ResetInUser?
suspend fun delete(code: String): Boolean
suspend fun getExpiringBefore(date: Instant): List<ResetInUser>

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package com.suitebde.usecases.application
import com.suitebde.models.users.User
import com.suitebde.repositories.associations.ICodesInEmailsRepository
import com.suitebde.repositories.users.IClientsInUsersRepository
import com.suitebde.repositories.users.IResetsInUsersRepository
import com.suitebde.usecases.associations.IDeleteCodeInEmailUseCase
import com.suitebde.usecases.auth.IDeleteAuthCodeUseCase
import com.suitebde.usecases.auth.IDeleteResetPasswordUseCase
import com.suitebde.usecases.users.IListUsersLastLoggedBeforeUseCase
import dev.kaccelero.commons.repositories.IDeleteChildModelSuspendUseCase
import dev.kaccelero.models.UUID
Expand All @@ -16,6 +18,8 @@ import kotlinx.datetime.minus
class ExpireUseCase(
private val codesInEmailsRepository: ICodesInEmailsRepository,
private val deleteCodeInEmailUseCase: IDeleteCodeInEmailUseCase,
private val resetsInUsersRepository: IResetsInUsersRepository,
private val deleteResetInUserUseCase: IDeleteResetPasswordUseCase,
private val clientsInUsersRepository: IClientsInUsersRepository,
private val deleteClientInUserUseCase: IDeleteAuthCodeUseCase,
private val listUsersLastLoggedBeforeUseCase: IListUsersLastLoggedBeforeUseCase,
Expand All @@ -26,6 +30,9 @@ class ExpireUseCase(
codesInEmailsRepository.getCodesInEmailsExpiringBefore(input).forEach {
deleteCodeInEmailUseCase(it.code)
}
resetsInUsersRepository.getExpiringBefore(input).forEach {
deleteResetInUserUseCase(it.code)
}
clientsInUsersRepository.getExpiringBefore(input).forEach {
deleteClientInUserUseCase(it.code)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.suitebde.usecases.auth

import com.suitebde.models.users.ResetInUser
import com.suitebde.repositories.users.IResetsInUsersRepository
import com.suitebde.usecases.users.IGetUserForEmailUseCase
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus

class CreateResetPasswordUseCase(
private val getUserForEmailUseCase: IGetUserForEmailUseCase,
private val repository: IResetsInUsersRepository,
) : ICreateResetPasswordUseCase {

override suspend fun invoke(input: String): ResetInUser? {
val user = getUserForEmailUseCase(input, false) ?: return null
return repository.create(
user.id,
Clock.System.now().plus(1, DateTimeUnit.HOUR, TimeZone.currentSystemDefault())
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.suitebde.usecases.auth

import com.suitebde.repositories.users.IResetsInUsersRepository

class DeleteResetPasswordUseCase(
private val repository: IResetsInUsersRepository,
) : IDeleteResetPasswordUseCase {

override suspend fun invoke(input: String) = repository.delete(input)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.suitebde.usecases.auth

import com.suitebde.models.users.ResetInUser
import com.suitebde.repositories.users.IResetsInUsersRepository

class GetResetPasswordUseCase(
private val repository: IResetsInUsersRepository,
) : IGetResetPasswordUseCase {

override suspend fun invoke(input: String): ResetInUser? = repository.get(input)

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.suitebde.usecases.auth

import com.suitebde.models.users.ResetInUser
import dev.kaccelero.usecases.ISuspendUseCase

interface ICreateResetPasswordUseCase : ISuspendUseCase<String, ResetInUser?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.suitebde.usecases.auth

import dev.kaccelero.usecases.ISuspendUseCase

interface IDeleteResetPasswordUseCase : ISuspendUseCase<String, Boolean>
Loading
Loading