Implement password reset process

This commit is contained in:
grimsi
2024-09-22 11:34:22 +02:00
parent a993b8a488
commit ae56793e6e
17 changed files with 340 additions and 52 deletions
+4 -2
View File
@@ -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}
+58 -2
View File
@@ -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
@@ -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}"
)
}
}
@@ -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)
}
@@ -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?
}