diff --git a/build.gradle.kts b/build.gradle.kts index be69f79..8ba36b0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/src/main/frontend/components/administration/NotificationManagement.tsx b/src/main/frontend/components/administration/NotificationManagement.tsx index 3d3db43..901f251 100644 --- a/src/main/frontend/components/administration/NotificationManagement.tsx +++ b/src/main/frontend/components/administration/NotificationManagement.tsx @@ -58,23 +58,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
- -
+ + isDisabled={!formik.values.notifications.providers.email.enabled}/> + isDisabled={!formik.values.notifications.providers.email.enabled}/> + isDisabled={!formik.values.notifications.providers.email.enabled}/> + isDisabled={!formik.values.notifications.providers.email.enabled}/> @@ -83,7 +82,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
{getConfigs("notifications.templates").map((template: ConfigEntryDto) => - + @@ -92,6 +118,36 @@ export default function LoginView() { + + + + {(onClose) => ( + <> + Request a password reset + + { + setResetEmail(event.target.value); + }} + type="email" + placeholder="Email" + /> + + + + + + + )} + +
); } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt b/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt index bf71a00..944d6b9 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/GameyfinApplication.kt @@ -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 diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt index 7976f7a..9c8e036 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt @@ -128,37 +128,43 @@ sealed class ConfigProperties( ) /** Notifications */ - data object NotificationsEnabled : ConfigProperties( - Boolean::class, - "notifications.enabled", - "Enable notifications", - false - ) + sealed class Notifications { + sealed class Providers { + sealed class Email { + data object Enabled : ConfigProperties( + Boolean::class, + "notifications.providers.email.enabled", + "Enable E-Mail notifications", + false + ) - data object NotificationsEmailHost : ConfigProperties( - String::class, - "notifications.providers.email.host", - "URL of the email server" - ) + data object Host : ConfigProperties( + String::class, + "notifications.providers.email.host", + "URL of the email server" + ) - data object NotificationsEmailPort : ConfigProperties( - Int::class, - "notifications.providers.email.port", - "Port of the email server", - 587 - ) + data object Port : ConfigProperties( + Int::class, + "notifications.providers.email.port", + "Port of the email server", + 587 + ) - data object NotificationsEmailUsername : ConfigProperties( - String::class, - "notifications.providers.email.username", - "Username for the email account" - ) + data object Username : ConfigProperties( + String::class, + "notifications.providers.email.username", + "Username for the email account" + ) - data object NotificationsEmailPassword : ConfigProperties( - String::class, - "notifications.providers.email.password", - "Password for the email account" - ) + data object Password : ConfigProperties( + String::class, + "notifications.providers.email.password", + "Password for the email account" + ) + } + } + } data object NotificationsTemplateNewUser : ConfigProperties( String::class, diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/events/AsyncConfig.kt b/src/main/kotlin/de/grimsi/gameyfin/core/events/AsyncConfig.kt new file mode 100644 index 0000000..6859acd --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/core/events/AsyncConfig.kt @@ -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 \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/events/Events.kt b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt similarity index 61% rename from src/main/kotlin/de/grimsi/gameyfin/notifications/events/Events.kt rename to src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt index 4affd3a..c77c35c 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/events/Events.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt @@ -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) diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt b/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt index 49d1611..f70a95e 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt @@ -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 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}" + ) } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt b/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt index 8286ef8..5cc7346 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/AbstractNotificationProvider.kt @@ -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) - } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt b/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt index b38ea9a..9aeb917 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/notifications/providers/EmailNotificationProvider.kt @@ -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() } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt new file mode 100644 index 0000000..f1b3a5a --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt @@ -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) { + + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt new file mode 100644 index 0000000..c3f1331 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt @@ -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()) +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt index efe1d06..dc00883 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/SessionService.kt @@ -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 = sessionRegistry.getAllSessions(user, false) + for (sessionInfo in sessions) { + sessionInfo.expireNow() + } + } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index 7b05e4d..8c660eb 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -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) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/PasswordResetToken.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/PasswordResetToken.kt new file mode 100644 index 0000000..8dcccde --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/entities/PasswordResetToken.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/PasswordResetTokenRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/PasswordResetTokenRepository.kt new file mode 100644 index 0000000..6d7fbb7 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/PasswordResetTokenRepository.kt @@ -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 { + fun findByToken(token: String): PasswordResetToken? + fun findByUser(user: User): PasswordResetToken? +} \ No newline at end of file