mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement password reset process
This commit is contained in:
+4
-2
@@ -34,11 +34,14 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Spring Boot & Kotlin
|
||||
// Spring Boot
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
||||
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
|
||||
|
||||
// Kotlin extensions
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
|
||||
// Reactive
|
||||
@@ -58,7 +61,6 @@ dependencies {
|
||||
// Persistence & I/O
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
|
||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
||||
implementation("commons-io:commons-io:2.16.1")
|
||||
|
||||
// SSO
|
||||
|
||||
@@ -58,23 +58,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row">
|
||||
<div className="flex flex-col flex-1">
|
||||
<ConfigFormField configElement={getConfig("notifications.enabled")}/>
|
||||
|
||||
<div className="flex flex-row gap-8">
|
||||
<div className="flex flex-col flex-1">
|
||||
<Section title="E-Mail"/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.host")}
|
||||
isDisabled={!formik.values.notifications.enabled}/>
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.port")}
|
||||
isDisabled={!formik.values.notifications.enabled}/>
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.username")}
|
||||
isDisabled={!formik.values.notifications.enabled}/>
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.password")}
|
||||
type="password"
|
||||
isDisabled={!formik.values.notifications.enabled}/>
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<Button onPress={() => verifyCredentials("email")}
|
||||
isDisabled={!(
|
||||
formik.values.notifications.enabled &&
|
||||
formik.values.notifications.providers.email.enabled &&
|
||||
formik.values.notifications.providers.email.host &&
|
||||
formik.values.notifications.providers.email.port &&
|
||||
formik.values.notifications.providers.email.username)}>Test</Button>
|
||||
@@ -83,7 +82,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
<Section title="Message Templates"/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{getConfigs("notifications.templates").map((template: ConfigEntryDto) =>
|
||||
<Card className="flex flex-row items-center gap-2 p-4">
|
||||
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
|
||||
<Button isIconOnly
|
||||
size="sm"
|
||||
onPress={() => openModal(template)}
|
||||
|
||||
@@ -7,7 +7,7 @@ const CheckboxInput = ({label, ...props}) => {
|
||||
const [field] = useField(props);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-1 items-center gap-2">
|
||||
<div className="flex flex-row flex-1 items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
{...field}
|
||||
{...props}
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {useLayoutEffect, useState} from "react";
|
||||
import {XCircle} from "@phosphor-icons/react";
|
||||
import {Button, Card, CardBody, CardHeader, Input, Link} from "@nextui-org/react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from "@nextui-org/react";
|
||||
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export default function LoginView() {
|
||||
const {state, login} = useAuth();
|
||||
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
||||
|
||||
const [hasError, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [username, setUsername] = useState<string>();
|
||||
const [password, setPassword] = useState<string>();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [resetEmail, setResetEmail] = useState<string>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -21,6 +40,11 @@ export default function LoginView() {
|
||||
}
|
||||
}, [state.user]);
|
||||
|
||||
async function resetPassword() {
|
||||
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
|
||||
toast.success("If the email address is registered, you will receive a message with further instructions.");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full gradient-primary">
|
||||
<Card className="m-auto p-12">
|
||||
@@ -84,7 +108,9 @@ export default function LoginView() {
|
||||
placeholder=""
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Link color="foreground" underline="always">Forgot password?</Link>
|
||||
<Link color="foreground" underline="always" onPress={onOpen}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
<Button color="primary" type="submit" isLoading={loading}>
|
||||
{loading ? "" : "Log in"}
|
||||
</Button>
|
||||
@@ -92,6 +118,36 @@ export default function LoginView() {
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
|
||||
<ModalBody>
|
||||
<Input
|
||||
onChange={(event: any) => {
|
||||
setResetEmail(event.target.value);
|
||||
}}
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
/>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={async () => {
|
||||
await resetPassword();
|
||||
onClose();
|
||||
}}>
|
||||
Send request
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package de.grimsi.gameyfin
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@EnableTransactionManagement
|
||||
class GameyfinApplication
|
||||
|
||||
|
||||
@@ -128,37 +128,43 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
)
|
||||
|
||||
/** Notifications */
|
||||
data object NotificationsEnabled : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"notifications.enabled",
|
||||
"Enable notifications",
|
||||
false
|
||||
)
|
||||
sealed class Notifications {
|
||||
sealed class Providers {
|
||||
sealed class Email {
|
||||
data object Enabled : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"notifications.providers.email.enabled",
|
||||
"Enable E-Mail notifications",
|
||||
false
|
||||
)
|
||||
|
||||
data object NotificationsEmailHost : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.host",
|
||||
"URL of the email server"
|
||||
)
|
||||
data object Host : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.host",
|
||||
"URL of the email server"
|
||||
)
|
||||
|
||||
data object NotificationsEmailPort : ConfigProperties<Int>(
|
||||
Int::class,
|
||||
"notifications.providers.email.port",
|
||||
"Port of the email server",
|
||||
587
|
||||
)
|
||||
data object Port : ConfigProperties<Int>(
|
||||
Int::class,
|
||||
"notifications.providers.email.port",
|
||||
"Port of the email server",
|
||||
587
|
||||
)
|
||||
|
||||
data object NotificationsEmailUsername : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.username",
|
||||
"Username for the email account"
|
||||
)
|
||||
data object Username : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.username",
|
||||
"Username for the email account"
|
||||
)
|
||||
|
||||
data object NotificationsEmailPassword : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.password",
|
||||
"Password for the email account"
|
||||
)
|
||||
data object Password : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.password",
|
||||
"Password for the email account"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data object NotificationsTemplateNewUser : ConfigProperties<String>(
|
||||
String::class,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.grimsi.gameyfin.core.events
|
||||
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.scheduling.annotation.EnableAsync
|
||||
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
class AsyncConfig
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
package de.grimsi.gameyfin.notifications.events
|
||||
package de.grimsi.gameyfin.core.events
|
||||
|
||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
class PasswordResetRequestEvent(source: Any) : ApplicationEvent(source)
|
||||
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken) : ApplicationEvent(source)
|
||||
|
||||
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package de.grimsi.gameyfin.notifications
|
||||
|
||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||
import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@@ -10,6 +15,11 @@ class NotificationService(
|
||||
private val applicationContext: ApplicationContext
|
||||
) {
|
||||
|
||||
val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
val enabled: Boolean
|
||||
get() = providers.any { it.enabled }
|
||||
|
||||
private val providers: List<AbstractNotificationProvider>
|
||||
get() = applicationContext.getBeansOfType(AbstractNotificationProvider::class.java).values.toList()
|
||||
|
||||
@@ -17,6 +27,25 @@ class NotificationService(
|
||||
val notificationProvider = providers.find { it.providerKey == provider }
|
||||
val credentialsProperties = Properties().apply { putAll(credentials) }
|
||||
return notificationProvider?.testCredentials(credentialsProperties)
|
||||
?: throw IllegalArgumentException("Provider $provider not found")
|
||||
?: throw IllegalArgumentException("Provider '$provider' not found")
|
||||
}
|
||||
|
||||
fun sendNotification(recipient: String, title: String, message: String) {
|
||||
providers.filter { it.enabled }.forEach { it.sendNotification(recipient, title, message) }
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(PasswordResetRequestEvent::class)
|
||||
fun onPasswordResetRequest(event: PasswordResetRequestEvent) {
|
||||
log.info { "Sending password reset request notification" }
|
||||
|
||||
val token = event.token
|
||||
|
||||
// TODO: Implement proper email template
|
||||
sendNotification(
|
||||
token.user.email,
|
||||
"Password Reset Request",
|
||||
"You have requested a password reset. Your token is ${token.token}"
|
||||
)
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -5,16 +5,18 @@ import java.util.*
|
||||
|
||||
abstract class AbstractNotificationProvider(
|
||||
val providerKey: String,
|
||||
private val config: ConfigService
|
||||
protected val config: ConfigService
|
||||
) {
|
||||
private val configKey = String.format("notifications.providers.%s.enabled", providerKey)
|
||||
|
||||
fun isEnabled(): Boolean {
|
||||
return config.get(configKey).toBoolean()
|
||||
protected companion object {
|
||||
const val BASE_KEY = "notifications.providers"
|
||||
}
|
||||
|
||||
private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey)
|
||||
|
||||
val enabled: Boolean
|
||||
get() = config.get(configKey).toBoolean()
|
||||
|
||||
abstract fun testCredentials(credentials: Properties): Boolean
|
||||
|
||||
abstract fun sendNotification(recipient: String, title: String, message: String)
|
||||
|
||||
}
|
||||
+40
-3
@@ -1,15 +1,29 @@
|
||||
package de.grimsi.gameyfin.notifications.providers
|
||||
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
import jakarta.mail.Message
|
||||
import jakarta.mail.MessagingException
|
||||
import jakarta.mail.Session
|
||||
import jakarta.mail.internet.InternetAddress
|
||||
import jakarta.mail.internet.MimeMessage
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class EmailNotificationProvider(
|
||||
configService: ConfigService
|
||||
) : AbstractNotificationProvider("email", configService) {
|
||||
config: ConfigService
|
||||
) : AbstractNotificationProvider("email", config) {
|
||||
|
||||
private val storedCredentials: Properties
|
||||
get() {
|
||||
val properties = Properties()
|
||||
properties["host"] = config.get(ConfigProperties.Notifications.Providers.Email.Host)
|
||||
properties["port"] = config.get(ConfigProperties.Notifications.Providers.Email.Port)
|
||||
properties["username"] = config.get(ConfigProperties.Notifications.Providers.Email.Username)
|
||||
properties["password"] = config.get(ConfigProperties.Notifications.Providers.Email.Password)
|
||||
return properties
|
||||
}
|
||||
|
||||
override fun testCredentials(credentials: Properties): Boolean {
|
||||
try {
|
||||
@@ -36,6 +50,29 @@ class EmailNotificationProvider(
|
||||
}
|
||||
|
||||
override fun sendNotification(recipient: String, title: String, message: String) {
|
||||
TODO("Not yet implemented")
|
||||
val credentials = storedCredentials
|
||||
val sessionProperties = Properties()
|
||||
sessionProperties["mail.smtp.auth"] = true
|
||||
sessionProperties["mail.smtp.starttls.enable"] = true
|
||||
sessionProperties["mail.smtp.host"] = credentials["host"]
|
||||
sessionProperties["mail.smtp.port"] = credentials["port"]
|
||||
|
||||
val session = Session.getInstance(sessionProperties)
|
||||
|
||||
val mimeMessage = MimeMessage(session)
|
||||
mimeMessage.setFrom(InternetAddress(credentials["username"] as String))
|
||||
mimeMessage.setRecipients(Message.RecipientType.TO, recipient)
|
||||
mimeMessage.subject = title
|
||||
mimeMessage.setText(message)
|
||||
|
||||
val transport = session.getTransport("smtp")
|
||||
transport.connect(
|
||||
credentials["host"] as String,
|
||||
credentials["port"] as Int,
|
||||
credentials["username"] as String,
|
||||
credentials["password"] as String
|
||||
)
|
||||
transport.sendMessage(mimeMessage, mimeMessage.allRecipients)
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
|
||||
@Endpoint
|
||||
@AnonymousAllowed
|
||||
class PasswordResetEndpoint(
|
||||
private val passwordResetService: PasswordResetService
|
||||
) {
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
passwordResetService.requestPasswordReset(email)
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, newPassword: String) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@Service
|
||||
class PasswordResetService(
|
||||
private val userService: UserService,
|
||||
private val sessionService: SessionService,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
private val passwordResetTokenRepository: PasswordResetTokenRepository
|
||||
) {
|
||||
|
||||
private companion object {
|
||||
val TOKEN_EXPIRATION = 24.hours
|
||||
}
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
|
||||
log.info { "Initiating password reset request for '${maskEmail(email)}'" }
|
||||
|
||||
val user = userService.getByEmail(email)
|
||||
|
||||
// A user can only reset its password if its email is confirmed, and it's not an SSO user
|
||||
if (user != null && user.email_confirmed && user.oidcProviderId == null) {
|
||||
val token = createPasswordResetToken(user)
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token))
|
||||
}
|
||||
|
||||
// Simulate a delay to prevent timing attacks
|
||||
Thread.sleep(secureRandom.nextLong(1024))
|
||||
}
|
||||
|
||||
fun createPasswordResetToken(user: User): PasswordResetToken {
|
||||
val token = PasswordResetToken(
|
||||
user = user,
|
||||
token = UUID.randomUUID().toString()
|
||||
)
|
||||
|
||||
passwordResetTokenRepository.findByUser(user)?.let {
|
||||
passwordResetTokenRepository.delete(it)
|
||||
}
|
||||
|
||||
return passwordResetTokenRepository.save(token)
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, newPassword: String) {
|
||||
val passwordResetToken =
|
||||
passwordResetTokenRepository.findByToken(token)
|
||||
?: throw IllegalArgumentException("Token not found")
|
||||
|
||||
if (passwordResetToken.isExpired) {
|
||||
throw IllegalStateException("Token is expired")
|
||||
}
|
||||
|
||||
val user = passwordResetToken.user
|
||||
|
||||
userService.updatePassword(user, newPassword)
|
||||
passwordResetTokenRepository.delete(passwordResetToken)
|
||||
sessionService.logoutAllSessions(user)
|
||||
}
|
||||
|
||||
private fun maskEmail(email: String): String {
|
||||
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
|
||||
return email.replace(regex, "*")
|
||||
}
|
||||
|
||||
private val PasswordResetToken.isExpired: Boolean
|
||||
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.core.session.SessionInformation
|
||||
@@ -19,4 +20,11 @@ class SessionService(private val sessionRegistry: SessionRegistry) {
|
||||
SecurityContextHolder.clearContext()
|
||||
}
|
||||
}
|
||||
|
||||
fun logoutAllSessions(user: User) {
|
||||
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(user, false)
|
||||
for (sessionInfo in sessions) {
|
||||
sessionInfo.expireNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,11 @@ class UserService(
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePassword(user: User, newPassword: String) {
|
||||
user.password = passwordEncoder.encode(newPassword)
|
||||
userRepository.save(user)
|
||||
}
|
||||
|
||||
fun deleteUser(username: String) {
|
||||
val user = userByUsername(username)
|
||||
userRepository.delete(user)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.grimsi.gameyfin.users.entities
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.OneToOne
|
||||
import org.hibernate.annotations.CreationTimestamp
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
class PasswordResetToken(
|
||||
@Id
|
||||
val token: String,
|
||||
|
||||
@OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
|
||||
val user: User,
|
||||
|
||||
@CreationTimestamp
|
||||
val createdOn: Instant? = null
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.grimsi.gameyfin.users.persistence
|
||||
|
||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface PasswordResetTokenRepository : JpaRepository<PasswordResetToken, String> {
|
||||
fun findByToken(token: String): PasswordResetToken?
|
||||
fun findByUser(user: User): PasswordResetToken?
|
||||
}
|
||||
Reference in New Issue
Block a user