Implemented email confirmation flow

Implemented user registration confirmation flow
This commit is contained in:
grimsi
2024-09-27 09:34:52 +02:00
parent 913ff9d289
commit e47ab8405f
19 changed files with 283 additions and 35 deletions
@@ -2,12 +2,12 @@ import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/Input";
import {Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
import {Form, Formik} from "formik";
import {Check, Info, Trash} from "@phosphor-icons/react";
import {ArrowCounterClockwise, Check, Info, Trash} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth";
import * as Yup from "yup";
import UserUpdateDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserUpdateDto";
import {UserEndpoint} from "Frontend/generated/endpoints";
import {EmailConfirmationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {toast} from "sonner";
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
@@ -120,8 +120,25 @@ export default function ProfileManagement() {
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/>
<Input name="email" label="Email" type="email" autocomplete="email"
isDisabled={auth.state.user?.managedBySso}/>
<div className="flex flex-row gap-4">
<Input name="email" label="Email" type="email" autocomplete="email"
isDisabled={auth.state.user?.managedBySso}/>
{auth.state.user?.emailConfirmed === false &&
<Tooltip content="Resend email confirmation message">
<Button isIconOnly
onPress={() => {
EmailConfirmationEndpoint.resendEmailConfirmation().then(
() => toast.success("You will receive an email shortly")
)
}}
variant="ghost"
className="size-14"
>
<ArrowCounterClockwise size={26}/>
</Button>
</Tooltip>
}
</div>
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
+4
View File
@@ -15,6 +15,7 @@ import {ProfileView} from "Frontend/views/ProfileView";
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
import {LogManagement} from "Frontend/components/administration/LogManagement";
import PasswordResetView from "Frontend/views/PasswordResetView";
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
export const routes = protectRoutes([
{
@@ -57,6 +58,9 @@ export const routes = protectRoutes([
},
{
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
},
{
path: '/confirm-email', element: <EmailConfirmationView/>, handle: {requiresLogin: true}
}
],
}
@@ -0,0 +1,76 @@
import {Card, CardBody, CardHeader} from "@nextui-org/react";
import {useNavigate, useSearchParams} from "react-router-dom";
import React, {useEffect, useState} from "react";
import {CheckCircle, Warning, WarningCircle} from "@phosphor-icons/react";
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
import {EmailConfirmationEndpoint} from "Frontend/generated/endpoints";
import {useAuth} from "Frontend/util/auth";
export default function EmailConfirmationView() {
const auth = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const [validationResult, setValidationResult] = useState<TokenValidationResult>(TokenValidationResult.INVALID);
useEffect(() => {
if (auth.state.user?.emailConfirmed === true) {
navigate("/");
}
}, []);
useEffect(() => {
let token = searchParams.get("token");
if (token) confirmEmail(token).then((result) => setValidationResult(result));
}, [searchParams]);
async function confirmEmail(token: string): Promise<TokenValidationResult> {
let result = await EmailConfirmationEndpoint.confirmEmail(token) as TokenValidationResult;
if (result === TokenValidationResult.VALID) {
setTimeout(() => window.location.reload(), 5000);
}
return result;
}
return (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody className="flex flex-row justify-center">
{validationResult === TokenValidationResult.VALID ?
<div className="flex flex-row items-center gap-4 text-success">
<CheckCircle size={40}/>
<p>
Email confirmed<br/>
You will be redirected shortly
</p>
</div>
: validationResult === TokenValidationResult.EXPIRED ?
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Expired token<br/>
Please request a new one
</p>
</div>
:
<div className="flex flex-row items-center gap-4 text-danger">
<Warning size={40}/>
<p>
Invalid token<br/>
Please try again
</p>
</div>
}
</CardBody>
</Card>
</div>
);
}
+15 -4
View File
@@ -5,13 +5,17 @@ import {Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from "@ne
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json";
import {Outlet, useNavigate} from "react-router-dom";
import {useAuth} from "Frontend/util/auth";
export default function MainLayout() {
const currentTitle = `Gameyfin - ${useRouteMetadata()?.title}` ?? 'Gameyfin';
useEffect(() => {
document.title = currentTitle;
}, [currentTitle]);
const navigate = useNavigate();
const auth = useAuth();
const routeMetadata = useRouteMetadata();
useEffect(() => {
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin';
window.addEventListener('popstate', () => document.title = newTitle);
}, []);
return (
<div className="flex flex-col min-h-svh">
@@ -21,6 +25,13 @@ export default function MainLayout() {
<GameyfinLogo className="h-10 fill-foreground"/>
</NavbarBrand>
<NavbarContent justify="end">
{auth.state.user?.emailConfirmed === false ?
<NavbarItem>
<small className="text-warning">Please confirm your email</small>
</NavbarItem>
:
""
}
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
@@ -70,7 +70,9 @@ class SetupDataLoader(
val superadmin = User(
username = "admin",
password = "admin",
email = "admin@gameyfin.org"
email = "admin@gameyfin.org",
emailConfirmed = true,
enabled = true
)
registerUserIfNotFound(superadmin, Roles.SUPERADMIN)
@@ -78,7 +80,9 @@ class SetupDataLoader(
val user = User(
username = "user",
password = "user",
email = "user@gameyfin.org"
email = "user@gameyfin.org",
emailConfirmed = true,
enabled = true
)
registerUserIfNotFound(user, Roles.USER)
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.core.events
import de.grimsi.gameyfin.shared.token.Token
import de.grimsi.gameyfin.shared.token.TokenType.EmailConfirmation
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import de.grimsi.gameyfin.users.entities.User
import org.springframework.context.ApplicationEvent
@@ -11,6 +12,9 @@ class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) :
class UserRegistrationEvent(source: Any, val newUser: User, val baseUrl: String) : ApplicationEvent(source)
class EmailNeedsConfirmationEvent(source: Any, val token: Token<EmailConfirmation>, val baseUrl: String) :
ApplicationEvent(source)
class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: User, val baseUrl: String) :
ApplicationEvent(source)
@@ -37,6 +37,7 @@ class SecurityConfig(
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
auth.requestMatchers("/setup").permitAll()
.requestMatchers("/reset-password").permitAll()
.requestMatchers("/accept-invitation").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/images/**").permitAll()
}
@@ -70,7 +71,6 @@ class SecurityConfig(
}
}
// TODO: Maybe switch to a database-backed client registration repository? Not sure if worth it.
@Bean
@Conditional(SsoEnabledCondition::class)
fun clientRegistrationRepository(): ClientRegistrationRepository? {
@@ -1,9 +1,6 @@
package de.grimsi.gameyfin.messages
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
import de.grimsi.gameyfin.core.events.*
import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
import de.grimsi.gameyfin.messages.templates.MessageTemplates
@@ -158,4 +155,25 @@ class MessageService(
mapOf("username" to user.username, "passwordResetLink" to event.baseUrl)
)
}
@Async
@EventListener(EmailNeedsConfirmationEvent::class)
fun onEmailNeedsConfirmation(event: EmailNeedsConfirmationEvent) {
if (!enabled) {
log.error { "No notification provider available, can't send email confirmation message" }
return
}
log.info { "Sending email confirmation notification" }
val user = event.token.user
val confirmationLink = event.baseUrl + "/confirm-email?token=${event.token.secret}"
sendNotification(
user.email,
"[Gameyfin] Email Confirmation",
MessageTemplates.EmailConfirmation,
mapOf("username" to user.username, "confirmationLink" to confirmationLink)
)
}
}
@@ -2,15 +2,11 @@ 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 jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.Type
import java.time.Instant
import java.util.UUID
import java.util.*
import kotlin.time.toJavaDuration
@Entity
@@ -29,5 +25,6 @@ class Token<T : TokenType>(
val createdOn: Instant? = null
) {
val expired: Boolean
get() = createdOn?.plus(type.expiration.toJavaDuration())!!.isBefore(Instant.now())
get() = type.expiration.isFinite() &&
createdOn?.plus(type.expiration.toJavaDuration())!!.isBefore(Instant.now())
}
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.shared.token
import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
abstract class TokenService<T : TokenType>(
private val type: T,
@@ -10,6 +11,7 @@ abstract class TokenService<T : TokenType>(
private val log = KotlinLogging.logger {}
@Transactional
open fun generate(user: User): Token<T> {
val token = Token(
user = user,
@@ -24,7 +26,8 @@ abstract class TokenService<T : TokenType>(
return tokenRepository.save(token)
}
fun get(secret: String, type: T): Token<T>? {
@Transactional
open fun get(secret: String, type: T): Token<T>? {
val token = tokenRepository.findBySecret(secret) ?: return null
return if (token.type == type) {
@@ -36,11 +39,17 @@ abstract class TokenService<T : TokenType>(
}
}
fun delete(token: Token<T>) {
tokenRepository.delete(token)
@Transactional
open fun delete(token: Token<T>) {
try {
tokenRepository.delete(token)
} catch (_: Exception) {
log.warn { "Token '$token' has already been deleted" }
}
}
fun validate(secret: String): TokenValidationResult {
@Transactional
open fun validate(secret: String): TokenValidationResult {
val token = tokenRepository.findBySecret(secret) ?: return TokenValidationResult.INVALID
return if (token.expired) TokenValidationResult.EXPIRED else TokenValidationResult.VALID
}
@@ -8,6 +8,6 @@ sealed class TokenType(
val expiration: Duration
) {
data object PasswordReset : TokenType("password-reset", 15.minutes)
data object EmailVerification : TokenType("email-verification", Duration.INFINITE)
data object EmailConfirmation : TokenType("email-verification", Duration.INFINITE)
data object Invitation : TokenType("invitation", Duration.INFINITE)
}
@@ -14,7 +14,11 @@ class TokenTypeUserType : UserType<TokenType> {
override fun returnedClass(): Class<TokenType> = TokenType::class.java
override fun equals(x: TokenType, y: TokenType): Boolean = x.key == y.key
override fun equals(x: TokenType?, y: TokenType?): Boolean {
if (x === y) return true
if (x == null || y == null) return false
return x.key == y.key
}
override fun hashCode(x: TokenType): Int = x.key.hashCode()
@@ -52,5 +56,13 @@ class TokenTypeUserType : UserType<TokenType> {
override fun disassemble(value: TokenType): Serializable = value.key
override fun assemble(cached: Serializable, owner: Any?): TokenType = cached as TokenType
override fun assemble(cached: Serializable, owner: Any?): TokenType {
val key = cached as? String ?: throw IllegalArgumentException("Invalid cached value: $cached")
val tokenTypeClass = TokenType::class
return tokenTypeClass.sealedSubclasses
.map { it.objectInstance ?: it.createInstance() }
.firstOrNull { it.key == key }
?: throw IllegalArgumentException("Unknown TokenType: $key")
}
}
@@ -4,12 +4,14 @@ import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto
import de.grimsi.gameyfin.users.emailconfirmation.EmailConfirmationService
import de.grimsi.gameyfin.users.entities.Avatar
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
@@ -39,6 +41,7 @@ class UserService(
private val passwordEncoder: PasswordEncoder,
private val roleService: RoleService,
private val sessionService: SessionService,
private val emailConfirmationService: EmailConfirmationService,
private val config: ConfigService,
private val eventPublisher: ApplicationEventPublisher
) : UserDetailsService {
@@ -168,6 +171,11 @@ class UserService(
} else {
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
}
if (!user.emailConfirmed) {
val token = emailConfirmationService.generate(user)
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
}
}
fun updateUser(username: String, updates: UserUpdateDto) {
@@ -183,6 +191,8 @@ class UserService(
updates.email?.let {
user.email = it
user.emailConfirmed = false
val token = emailConfirmationService.generate(user)
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
}
userRepository.save(user)
@@ -0,0 +1,28 @@
package de.grimsi.gameyfin.users.emailconfirmation
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService
import jakarta.annotation.security.PermitAll
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
@Endpoint
class EmailConfirmationEndpoint(
private val emailConfirmationService: EmailConfirmationService,
private val userService: UserService
) {
@PermitAll
fun confirmEmail(token: String): TokenValidationResult {
return emailConfirmationService.confirmEmail(token)
}
@PermitAll
fun resendEmailConfirmation() {
val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.getByUsername(auth.name)?.let {
emailConfirmationService.resendEmailConfirmation(it)
}
}
}
@@ -0,0 +1,54 @@
package de.grimsi.gameyfin.users.emailconfirmation
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent
import de.grimsi.gameyfin.shared.token.TokenRepository
import de.grimsi.gameyfin.shared.token.TokenService
import de.grimsi.gameyfin.shared.token.TokenType.EmailConfirmation
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
@Service
class EmailConfirmationService(
tokenRepository: TokenRepository,
private val userRepository: UserRepository,
private val eventPublisher: ApplicationEventPublisher
) : TokenService<EmailConfirmation>(EmailConfirmation, tokenRepository) {
val log: KLogger = KotlinLogging.logger {}
fun confirmEmail(secret: String): TokenValidationResult {
val emailConfirmationToken = get(secret, EmailConfirmation)
?: return TokenValidationResult.INVALID
if (emailConfirmationToken.expired) {
return TokenValidationResult.EXPIRED
}
val user = emailConfirmationToken.user
confirmEmail(user)
delete(emailConfirmationToken)
return TokenValidationResult.VALID
}
fun resendEmailConfirmation(user: User) {
if (user.emailConfirmed) {
log.error { "User '${user.username}' has already confirmed their email address" }
return
}
val token = generate(user)
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(user, token, Utils.getBaseUrl()))
}
private fun confirmEmail(user: User) {
user.emailConfirmed = true
userRepository.save(user)
}
}
@@ -29,7 +29,7 @@ class User(
var emailConfirmed: Boolean = false,
var enabled: Boolean = true,
var enabled: Boolean = false,
@Embedded
@Nullable
@@ -1,10 +1,11 @@
package de.grimsi.gameyfin.users
package de.grimsi.gameyfin.users.passwordreset
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.shared.token.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService
import jakarta.annotation.security.RolesAllowed
@Endpoint
@@ -1,10 +1,12 @@
package de.grimsi.gameyfin.users
package de.grimsi.gameyfin.users.passwordreset
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
import de.grimsi.gameyfin.messages.MessageService
import de.grimsi.gameyfin.shared.token.*
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import de.grimsi.gameyfin.users.SessionService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher
@@ -89,8 +91,8 @@ class PasswordResetService(
Thread.sleep(secureRandom.nextLong(1024))
}
fun resetPassword(token: String, newPassword: String): TokenValidationResult {
val passwordResetToken = get(token, PasswordReset)
fun resetPassword(secret: String, newPassword: String): TokenValidationResult {
val passwordResetToken = get(secret, PasswordReset)
?: return TokenValidationResult.INVALID
if (passwordResetToken.expired) {
@@ -1,8 +1,9 @@
package de.grimsi.gameyfin.users
package de.grimsi.gameyfin.users.registration
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import jakarta.annotation.security.RolesAllowed