Implemented generic token handling

This commit is contained in:
grimsi
2024-09-26 16:50:18 +02:00
parent ff0d34e3a5
commit b28b5d048c
36 changed files with 369 additions and 204 deletions
@@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {Button, Card, Tooltip, useDisclosure} from "@nextui-org/react";
import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/notifications/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/notifications/EditTemplateModel";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
function MessageManagementLayout({getConfig, getConfigs, formik}: any) {
const editorModal = useDisclosure();
const testNotificationModal = useDisclosure();
@@ -25,13 +25,13 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
async function verifyCredentials(provider: string) {
const credentials: Record<string, any> = {
host: formik.values.notifications.providers.email.host,
port: formik.values.notifications.providers.email.port,
username: formik.values.notifications.providers.email.username,
password: formik.values.notifications.providers.email.password
host: formik.values.messages.providers.email.host,
port: formik.values.messages.providers.email.port,
username: formik.values.messages.providers.email.username,
password: formik.values.messages.providers.email.password
}
const areCredentialsValid = await NotificationEndpoint.verifyCredentials(provider, credentials);
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
if (areCredentialsValid) {
toast.success("Credentials are valid")
@@ -57,22 +57,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
<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.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("notifications.providers.email.port")}
isDisabled={!formik.values.notifications.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("notifications.providers.email.username")}
isDisabled={!formik.values.notifications.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("notifications.providers.email.password")}
<ConfigFormField configElement={getConfig("messages.providers.email.enabled")}/>
<ConfigFormField configElement={getConfig("messages.providers.email.host")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.port")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.username")}
isDisabled={!formik.values.messages.providers.email.enabled}/>
<ConfigFormField configElement={getConfig("messages.providers.email.password")}
type="password"
isDisabled={!formik.values.notifications.providers.email.enabled}/>
isDisabled={!formik.values.messages.providers.email.enabled}/>
<Button onPress={() => verifyCredentials("email")}
isDisabled={!(
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>
formik.values.messages.providers.email.enabled &&
formik.values.messages.providers.email.host &&
formik.values.messages.providers.email.port &&
formik.values.messages.providers.email.username)}>Test</Button>
</div>
<div className="flex flex-col flex-1">
<Section title="Message Templates"/>
@@ -119,4 +119,4 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
);
}
export const NotificationManagement = withConfigPage(NotificationManagementLayout, "Notifications", "notifications");
export const MessageManagement = withConfigPage(MessageManagementLayout, "Messages", "messages");
@@ -12,8 +12,8 @@ import {
} from "@nextui-org/react";
import {toast} from "sonner";
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/TemplateType";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/messages/templates/TemplateType";
interface EditTemplateModalProps {
isOpen: boolean;
@@ -41,6 +41,12 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
}
function templateContainsAllRequiredPlaceholders(): boolean {
if (!selectedTemplate || !selectedTemplate.availablePlaceholders) return false;
return selectedTemplate.availablePlaceholders
.every((p) => templateContent.includes(`{${p}}`))
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
<ModalContent>
@@ -98,15 +104,17 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
Cancel
</Button>
<Button color="primary" onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
toast.success("Template saved");
onClose();
}
}}>
<Button color="primary"
isDisabled={!templateContainsAllRequiredPlaceholders()}
onPress={async () => {
if (selectedTemplate) {
await saveTemplate(selectedTemplate);
toast.success("Template saved");
onClose();
}
}}>
Save
</Button>
</ModalFooter>
@@ -3,9 +3,9 @@ import {Form, Formik} from "formik";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {toast} from "sonner";
import Input from "Frontend/components/general/Input";
import {NotificationEndpoint} from "Frontend/generated/endpoints";
import {MessageEndpoint} from "Frontend/generated/endpoints";
import * as Yup from "yup";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
interface SendTestNotificationModalProps {
isOpen: boolean;
@@ -35,7 +35,7 @@ export default function SendTestNotificationModal({
<Formik
initialValues={{}}
onSubmit={async (values) => {
await NotificationEndpoint.sendTestNotification(selectedTemplate?.key, values);
await MessageEndpoint.sendTestNotification(selectedTemplate?.key, values);
toast.success("Test notification to you has been sent");
onClose();
}}
+2 -2
View File
@@ -12,7 +12,7 @@ import ProfileManagement from "Frontend/components/administration/ProfileManagem
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
import {AdministrationView} from "Frontend/views/AdministrationView";
import {ProfileView} from "Frontend/views/ProfileView";
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
import {LogManagement} from "Frontend/components/administration/LogManagement";
import PasswordResetView from "Frontend/views/PasswordResetView";
@@ -43,7 +43,7 @@ export const routes = protectRoutes([
{path: 'libraries', element: <LibraryManagement/>},
{path: 'users', element: <UserManagement/>},
{path: 'sso', element: <SsoManagement/>},
{path: 'notifications', element: <NotificationManagement/>},
{path: 'messages', element: <MessageManagement/>},
{path: 'logs', element: <LogManagement/>}
]
}
@@ -18,8 +18,8 @@ const menuItems: MenuItem[] = [
icon: <LockKey/>
},
{
title: "Notifications",
url: "notifications",
title: "Messages",
url: "messages",
icon: <Envelope/>
},
{
+2 -2
View File
@@ -17,7 +17,7 @@ import {
} from "@nextui-org/react";
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
import {useNavigate} from "react-router-dom";
import {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
export default function LoginView() {
@@ -35,7 +35,7 @@ export default function LoginView() {
const navigate = useNavigate();
useEffect(() => {
NotificationEndpoint.isEnabled().then(setCanResetPassword);
MessageEndpoint.isEnabled().then(setCanResetPassword);
}, []);
useEffect(() => {
@@ -7,7 +7,7 @@ import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react";
import {toast} from "sonner";
import PasswordResetResult from "Frontend/generated/de/grimsi/gameyfin/users/dto/PasswordResetResult";
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
export default function PasswordResetView() {
const [searchParams, setSearchParams] = useSearchParams();
@@ -21,17 +21,17 @@ export default function PasswordResetView() {
async function resetPassword(values: any) {
let token = searchParams.get("token") as string;
let result = await PasswordResetEndpoint.resetPassword(token, values.password) as PasswordResetResult;
let result = await PasswordResetEndpoint.resetPassword(token, values.password) as TokenValidationResult;
switch (result) {
case PasswordResetResult.SUCCESS:
case TokenValidationResult.VALID:
toast.success("Password reset successfully");
navigate("/", {replace: true});
break;
case PasswordResetResult.EXPIRED_TOKEN:
case TokenValidationResult.EXPIRED:
toast.error("Token is expired");
break;
case PasswordResetResult.INVALID_TOKEN:
case TokenValidationResult.INVALID:
default:
toast.error("Token is invalid");
break
@@ -141,39 +141,39 @@ sealed class ConfigProperties<T : Serializable>(
}
}
/** Notifications */
sealed class Notifications {
/** Messages */
sealed class Messages {
sealed class Providers {
sealed class Email {
data object Enabled : ConfigProperties<Boolean>(
Boolean::class,
"notifications.providers.email.enabled",
"messages.providers.email.enabled",
"Enable E-Mail notifications",
false
)
data object Host : ConfigProperties<String>(
String::class,
"notifications.providers.email.host",
"messages.providers.email.host",
"URL of the email server"
)
data object Port : ConfigProperties<Int>(
Int::class,
"notifications.providers.email.port",
"messages.providers.email.port",
"Port of the email server",
587
)
data object Username : ConfigProperties<String>(
String::class,
"notifications.providers.email.username",
"messages.providers.email.username",
"Username for the email account"
)
data object Password : ConfigProperties<String>(
String::class,
"notifications.providers.email.password",
"messages.providers.email.password",
"Password for the email account"
)
}
@@ -1,13 +1,14 @@
package de.grimsi.gameyfin.core.events
import de.grimsi.gameyfin.users.entities.PasswordResetToken
import de.grimsi.gameyfin.shared.token.Token
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import org.springframework.context.ApplicationEvent
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken, val baseUrl: String) :
class PasswordResetRequestEvent(source: Any, val token: Token<PasswordReset>, val baseUrl: String) :
ApplicationEvent(source)
class GameRequestEvent(source: Any) : ApplicationEvent(source)
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications
package de.grimsi.gameyfin.messages
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
@@ -7,20 +7,20 @@ import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Roles.Names.ADMIN)
class NotificationEndpoint(
private val notificationService: NotificationService
class MessageEndpoint(
private val messageService: MessageService
) {
@AnonymousAllowed
fun isEnabled(): Boolean {
return notificationService.enabled
return messageService.enabled
}
fun verifyCredentials(provider: String, credentials: Map<String, Any>): Boolean {
return notificationService.testCredentials(provider, credentials)
return messageService.testCredentials(provider, credentials)
}
fun sendTestNotification(templateKey: String, placeholders: Map<String, String>): Boolean {
return notificationService.sendTestNotification(templateKey, placeholders)
return messageService.sendTestNotification(templateKey, placeholders)
}
}
@@ -1,9 +1,9 @@
package de.grimsi.gameyfin.notifications
package de.grimsi.gameyfin.messages
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider
import de.grimsi.gameyfin.notifications.templates.MessageTemplateService
import de.grimsi.gameyfin.notifications.templates.MessageTemplates
import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
import de.grimsi.gameyfin.messages.templates.MessageTemplates
import de.grimsi.gameyfin.users.UserService
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -18,7 +18,7 @@ import java.util.*
@EnableAsync
@Service
class NotificationService(
class MessageService(
private val applicationContext: ApplicationContext,
private val templateService: MessageTemplateService,
private val userService: UserService
@@ -29,8 +29,8 @@ class NotificationService(
val enabled: Boolean
get() = providers.any { it.enabled }
private val providers: List<AbstractNotificationProvider>
get() = applicationContext.getBeansOfType(AbstractNotificationProvider::class.java).values.toList()
private val providers: List<AbstractMessageProvider>
get() = applicationContext.getBeansOfType(AbstractMessageProvider::class.java).values.toList()
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
val notificationProvider = providers.find { it.providerKey == provider }
@@ -87,7 +87,7 @@ class NotificationService(
log.info { "Sending password reset request notification" }
val token = event.token
val resetLink = event.baseUrl + "/reset-password?token=${token.token}"
val resetLink = event.baseUrl + "/reset-password?token=${token.secret}"
sendNotification(
token.user.email,
"[Gameyfin] Password Reset Request",
@@ -1,16 +1,16 @@
package de.grimsi.gameyfin.notifications.providers
package de.grimsi.gameyfin.messages.providers
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.notifications.templates.TemplateType
import de.grimsi.gameyfin.messages.templates.TemplateType
import java.util.*
abstract class AbstractNotificationProvider(
abstract class AbstractMessageProvider(
val providerKey: String,
val supportedTemplateType: TemplateType,
protected val config: ConfigService
) {
protected companion object {
const val BASE_KEY = "notifications.providers"
const val BASE_KEY = "messages.providers"
}
private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey)
@@ -1,8 +1,8 @@
package de.grimsi.gameyfin.notifications.providers
package de.grimsi.gameyfin.messages.providers
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.notifications.templates.TemplateType
import de.grimsi.gameyfin.messages.templates.TemplateType
import jakarta.mail.Message
import jakarta.mail.MessagingException
import jakarta.mail.Session
@@ -12,17 +12,17 @@ import org.springframework.stereotype.Service
import java.util.*
@Service
class EmailNotificationProvider(
class EmailMessageProvider(
config: ConfigService
) : AbstractNotificationProvider("email", TemplateType.MJML, config) {
) : AbstractMessageProvider("email", TemplateType.MJML, 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)
properties["host"] = config.get(ConfigProperties.Messages.Providers.Email.Host)
properties["port"] = config.get(ConfigProperties.Messages.Providers.Email.Port)
properties["username"] = config.get(ConfigProperties.Messages.Providers.Email.Username)
properties["password"] = config.get(ConfigProperties.Messages.Providers.Email.Password)
return properties
}
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications.templates
package de.grimsi.gameyfin.messages.templates
data class MessageTemplateDto(
val key: String,
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications.templates
package de.grimsi.gameyfin.messages.templates
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications.templates
package de.grimsi.gameyfin.messages.templates
import ch.digitalfondue.mjml4j.Mjml4j
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -14,7 +14,7 @@ class MessageTemplateService {
companion object {
private const val TEMPLATE_PATH = "templates"
private const val DEFAULT_TEMPLATE_PATH = "templates/notifications"
private const val DEFAULT_TEMPLATE_PATH = "templates/messages"
}
private val log = KotlinLogging.logger {}
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications.templates
package de.grimsi.gameyfin.messages.templates
sealed class MessageTemplates(
val key: String,
@@ -20,6 +20,13 @@ sealed class MessageTemplates(
listOf("username")
)
data object EmailConfirmation : MessageTemplates(
"email-confirmation",
"Email Confirmation",
"Template for the email confirmation message",
listOf("username", "confirmationLink")
)
data object PasswordResetRequest : MessageTemplates(
"password-reset-request",
"Password Reset Request",
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications.templates
package de.grimsi.gameyfin.messages.templates
abstract class MjmlTemplate {
companion object {
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.notifications.templates
package de.grimsi.gameyfin.messages.templates
enum class TemplateType(val extension: String) {
MJML("mjml"), TEXT("txt")
@@ -0,0 +1,33 @@
package de.grimsi.gameyfin.shared.token
import de.grimsi.gameyfin.core.security.EncryptionConverter
import de.grimsi.gameyfin.users.entities.User
import jakarta.persistence.Convert
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.OneToOne
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.Type
import java.time.Instant
import java.util.UUID
import kotlin.time.toJavaDuration
@Entity
class Token<T : TokenType>(
@Id
@Convert(converter = EncryptionConverter::class)
val secret: String = UUID.randomUUID().toString(),
@Type(TokenTypeUserType::class)
val type: T,
@OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
val user: User,
@CreationTimestamp
val createdOn: Instant? = null
) {
val expired: Boolean
get() = createdOn?.plus(type.expiration.toJavaDuration())!!.isBefore(Instant.now())
}
@@ -0,0 +1,9 @@
package de.grimsi.gameyfin.shared.token
import de.grimsi.gameyfin.users.entities.User
import org.springframework.data.jpa.repository.JpaRepository
interface TokenRepository : JpaRepository<Token<*>, String> {
fun findBySecret(secret: String): Token<*>?
fun <T : TokenType> findByUserAndType(user: User, type: T): Token<T>?
}
@@ -0,0 +1,47 @@
package de.grimsi.gameyfin.shared.token
import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging
abstract class TokenService<T : TokenType>(
private val type: T,
private val tokenRepository: TokenRepository
) {
private val log = KotlinLogging.logger {}
open fun generate(user: User): Token<T> {
val token = Token(
user = user,
type = type
)
tokenRepository.findByUserAndType(user, type)?.let {
log.warn { "Deleting existing '${it.type}' token for user '${user.username}'" }
delete(it)
}
return tokenRepository.save(token)
}
fun get(secret: String, type: T): Token<T>? {
val token = tokenRepository.findBySecret(secret) ?: return null
return if (token.type == type) {
@Suppress("UNCHECKED_CAST")
token as Token<T>
} else {
log.error { "Token '$token' is not of type '$type'" }
null
}
}
fun delete(token: Token<T>) {
tokenRepository.delete(token)
}
fun validate(secret: String): TokenValidationResult {
val token = tokenRepository.findBySecret(secret) ?: return TokenValidationResult.INVALID
return if (token.expired) TokenValidationResult.EXPIRED else TokenValidationResult.VALID
}
}
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.shared.token
import java.io.Serializable
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
sealed class TokenType(
val key: String,
val expiration: Duration
) : Serializable {
data object PasswordReset : TokenType("password-reset", 15.minutes)
data object EmailVerification : TokenType("email-verification", Duration.INFINITE)
data object Invitation : TokenType("invitation", Duration.INFINITE)
fun readResolve(): Any = when (this) {
PasswordReset -> PasswordReset
EmailVerification -> EmailVerification
Invitation -> Invitation
}
}
@@ -0,0 +1,55 @@
package de.grimsi.gameyfin.shared.token
import org.hibernate.engine.spi.SharedSessionContractImplementor
import org.hibernate.usertype.UserType
import java.io.Serializable
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
class TokenTypeUserType : UserType<TokenType> {
override fun getSqlType(): Int = Types.VARCHAR
override fun returnedClass(): Class<TokenType> = TokenType::class.java
override fun equals(x: TokenType, y: TokenType): Boolean = x.key == y.key
override fun hashCode(x: TokenType): Int = x.key.hashCode()
override fun nullSafeGet(
rs: ResultSet,
position: Int,
session: SharedSessionContractImplementor,
owner: Any?
): TokenType? {
val key = rs.getString(position) ?: return null
return when (key) {
TokenType.PasswordReset.key -> TokenType.PasswordReset
TokenType.EmailVerification.key -> TokenType.EmailVerification
TokenType.Invitation.key -> TokenType.Invitation
else -> throw IllegalArgumentException("Unknown TokenType key: $key")
}
}
override fun nullSafeSet(
st: PreparedStatement,
value: TokenType?,
index: Int,
session: SharedSessionContractImplementor
) {
if (value == null) {
st.setNull(index, Types.VARCHAR)
} else {
st.setString(index, value.key)
}
}
override fun deepCopy(value: TokenType): TokenType = value
override fun isMutable(): Boolean = false
override fun disassemble(value: TokenType): Serializable = value.key
override fun assemble(cached: Serializable, owner: Any?): TokenType = cached as TokenType
}
@@ -0,0 +1,5 @@
package de.grimsi.gameyfin.shared.token
enum class TokenValidationResult() {
VALID, INVALID, EXPIRED
}
@@ -3,13 +3,14 @@ package de.grimsi.gameyfin.users
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.users.dto.PasswordResetResult
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import jakarta.annotation.security.RolesAllowed
@Endpoint
@AnonymousAllowed
class PasswordResetEndpoint(
private val passwordResetService: PasswordResetService
private val passwordResetService: PasswordResetService,
private val userService: UserService
) {
fun requestPasswordReset(email: String) {
@@ -20,10 +21,10 @@ class PasswordResetEndpoint(
@RolesAllowed(Roles.Names.ADMIN)
fun createPasswordResetTokenForUser(username: String): String {
return passwordResetService.createPasswordResetToken(username)
return passwordResetService.generate(username).secret
}
fun resetPassword(token: String, newPassword: String): PasswordResetResult {
return passwordResetService.resetPassword(token, newPassword)
fun resetPassword(secret: String, newPassword: String): TokenValidationResult {
return passwordResetService.resetPassword(secret, newPassword)
}
}
@@ -2,43 +2,63 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
import de.grimsi.gameyfin.notifications.NotificationService
import de.grimsi.gameyfin.users.dto.PasswordResetResult
import de.grimsi.gameyfin.users.entities.PasswordResetToken
import de.grimsi.gameyfin.messages.MessageService
import de.grimsi.gameyfin.shared.token.Token
import de.grimsi.gameyfin.shared.token.TokenRepository
import de.grimsi.gameyfin.shared.token.TokenService
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import de.grimsi.gameyfin.shared.token.TokenValidationResult
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(
tokenRepository: TokenRepository,
private val userService: UserService,
private val messageService: MessageService,
private val sessionService: SessionService,
private val notificationService: NotificationService,
private val eventPublisher: ApplicationEventPublisher,
private val passwordResetTokenRepository: PasswordResetTokenRepository
) {
private companion object {
val TOKEN_EXPIRATION = 24.hours
}
private val eventPublisher: ApplicationEventPublisher
) : TokenService<PasswordReset>(PasswordReset, tokenRepository) {
private val log = KotlinLogging.logger {}
private val secureRandom = SecureRandom()
private val PasswordResetToken.isExpired: Boolean
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
private val baseUrl: String
get() = Utils.getBaseUrl()
override fun generate(user: User): Token<PasswordReset> {
if (user.oidcProviderId != null) {
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
}
return super.generate(user)
}
/**
* Admins should be able to create password reset tokens for users when the following conditions are met:
* - E-Mail notifications are not enabled
* - The user has no confirmed email address
* - The user is not managed externally
*/
fun generate(username: String): Token<PasswordReset> {
if (messageService.enabled) {
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
}
val user = userService.getByUsername(username)
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
if (user.emailConfirmed == true) {
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
}
return generate(user)
}
/**
* Users can request a password reset when the following conditions are met:
* - The user has confirmed their email address
@@ -67,66 +87,26 @@ class PasswordResetService(
return
}
val token = createPasswordResetToken(user)
val token = generate(user)
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
// Simulate a delay to prevent timing attacks
Thread.sleep(secureRandom.nextLong(1024))
}
fun createPasswordResetToken(user: User): PasswordResetToken {
if (user.oidcProviderId != null) {
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
}
fun resetPassword(token: String, newPassword: String): TokenValidationResult {
val passwordResetToken = get(token, PasswordReset)
?: return TokenValidationResult.INVALID
val token = PasswordResetToken(
user = user,
token = UUID.randomUUID().toString()
)
passwordResetTokenRepository.findByUser(user)?.let {
passwordResetTokenRepository.delete(it)
}
return passwordResetTokenRepository.save(token)
}
/**
* Admins should be able to create password reset tokens for users when the following conditions are met:
* - E-Mail notifications are not enabled
* - The user has no confirmed email address
* - The user is not managed externally
*/
fun createPasswordResetToken(username: String): String {
if (notificationService.enabled) {
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
}
val user = userService.getByUsername(username)
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
if (user.emailConfirmed == true) {
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
}
return createPasswordResetToken(user).token
}
fun resetPassword(token: String, newPassword: String): PasswordResetResult {
val passwordResetToken =
passwordResetTokenRepository.findByToken(token)
?: return PasswordResetResult.INVALID_TOKEN
if (passwordResetToken.isExpired) {
return PasswordResetResult.EXPIRED_TOKEN
if (passwordResetToken.expired) {
return TokenValidationResult.EXPIRED
}
val user = passwordResetToken.user
userService.updatePassword(user, newPassword)
passwordResetTokenRepository.delete(passwordResetToken)
delete(passwordResetToken)
sessionService.logoutAllSessions(user)
return PasswordResetResult.SUCCESS
return TokenValidationResult.VALID
}
}
@@ -8,6 +8,7 @@ import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
@@ -26,12 +27,14 @@ import java.io.InputStream
@Transactional
class UserService(
private val userRepository: UserRepository,
private val avatarStore: AvatarContentStore,
private val passwordEncoder: PasswordEncoder,
private val roleService: RoleService,
private val sessionService: SessionService,
private val avatarStore: AvatarContentStore
private val sessionService: SessionService
) : UserDetailsService {
private val log = KotlinLogging.logger {}
override fun loadUserByUsername(username: String): UserDetails {
val user = userByUsername(username)
@@ -125,15 +128,18 @@ class UserService(
val user = userByUsername(username)
updates.username?.let { user.username = it }
updates.password?.let { user.password = passwordEncoder.encode(it) }
updates.email?.let { user.email = it }
userRepository.save(user)
// If user changes password, all sessions should be invalidated
if (updates.password != null) {
updates.password?.let {
user.password = passwordEncoder.encode(it)
sessionService.logoutAllSessions()
}
updates.email?.let {
user.email = it
user.emailConfirmed = false
}
userRepository.save(user)
}
fun updatePassword(user: User, newPassword: String) {
@@ -1,5 +0,0 @@
package de.grimsi.gameyfin.users.dto
enum class PasswordResetResult() {
SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN
}
@@ -1,19 +0,0 @@
package de.grimsi.gameyfin.users.entities
import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import java.time.Instant
@Entity
class PasswordResetToken(
@Id
@Convert(converter = EncryptionConverter::class)
val token: String,
@OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
val user: User,
@CreationTimestamp
val createdOn: Instant? = null
)
@@ -2,5 +2,7 @@ package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.users.entities.Avatar
import org.springframework.content.commons.store.ContentStore
import org.springframework.stereotype.Repository
@Repository
interface AvatarContentStore : ContentStore<Avatar, String>
@@ -1,10 +0,0 @@
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?
}
@@ -0,0 +1,25 @@
<mjml>
<mj-head>
<mj-title>Please confirm your email address</mj-title>
<mj-attributes>
<mj-all font-family="Arial, sans-serif"/>
<mj-text font-size="16px"/>
</mj-attributes>
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-image width="128px" src="{logo}"/>
<mj-image height="2px" padding-bottom="20px" src="{gradient}"/>
<mj-text font-size="20px" font-family="helvetica">Hey {username},
<br/>
<br/>
</mj-text>
<mj-text>please confirm your email address using the following link: {confirmationLink}
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>