mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
Implemented email confirmation flow
Implemented user registration confirmation flow
This commit is contained in:
@@ -2,12 +2,12 @@ import Section from "Frontend/components/general/Section";
|
|||||||
import Input from "Frontend/components/general/Input";
|
import Input from "Frontend/components/general/Input";
|
||||||
import {Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
|
import {Button, Input as NextUiInput, Tooltip} from "@nextui-org/react";
|
||||||
import {Form, Formik} from "formik";
|
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 React, {useEffect, useState} from "react";
|
||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import UserUpdateDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserUpdateDto";
|
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 {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
|
import {removeAvatar, uploadAvatar} from "Frontend/endpoints/AvatarEndpoint";
|
||||||
@@ -120,8 +120,25 @@ export default function ProfileManagement() {
|
|||||||
<Section title="Personal information"/>
|
<Section title="Personal information"/>
|
||||||
<Input name="username" label="Username" type="text" autocomplete="username"
|
<Input name="username" label="Username" type="text" autocomplete="username"
|
||||||
isDisabled={auth.state.user?.managedBySso}/>
|
isDisabled={auth.state.user?.managedBySso}/>
|
||||||
<Input name="email" label="Email" type="email" autocomplete="email"
|
<div className="flex flex-row gap-4">
|
||||||
isDisabled={auth.state.user?.managedBySso}/>
|
<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"/>
|
<Section title="Security"/>
|
||||||
<Input name="newPassword" label="New Password" type="password"
|
<Input name="newPassword" label="New Password" type="password"
|
||||||
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {ProfileView} from "Frontend/views/ProfileView";
|
|||||||
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
|
import {MessageManagement} from "Frontend/components/administration/MessageManagement";
|
||||||
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||||
import PasswordResetView from "Frontend/views/PasswordResetView";
|
import PasswordResetView from "Frontend/views/PasswordResetView";
|
||||||
|
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
|
||||||
|
|
||||||
export const routes = protectRoutes([
|
export const routes = protectRoutes([
|
||||||
{
|
{
|
||||||
@@ -57,6 +58,9 @@ export const routes = protectRoutes([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,17 @@ import {Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from "@ne
|
|||||||
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
||||||
import * as PackageJson from "../../../../package.json";
|
import * as PackageJson from "../../../../package.json";
|
||||||
import {Outlet, useNavigate} from "react-router-dom";
|
import {Outlet, useNavigate} from "react-router-dom";
|
||||||
|
import {useAuth} from "Frontend/util/auth";
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const currentTitle = `Gameyfin - ${useRouteMetadata()?.title}` ?? 'Gameyfin';
|
|
||||||
useEffect(() => {
|
|
||||||
document.title = currentTitle;
|
|
||||||
}, [currentTitle]);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const auth = useAuth();
|
||||||
|
const routeMetadata = useRouteMetadata();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let newTitle = `Gameyfin - ${routeMetadata?.title}` ?? 'Gameyfin';
|
||||||
|
window.addEventListener('popstate', () => document.title = newTitle);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-svh">
|
<div className="flex flex-col min-h-svh">
|
||||||
@@ -21,6 +25,13 @@ export default function MainLayout() {
|
|||||||
<GameyfinLogo className="h-10 fill-foreground"/>
|
<GameyfinLogo className="h-10 fill-foreground"/>
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
<NavbarContent justify="end">
|
<NavbarContent justify="end">
|
||||||
|
{auth.state.user?.emailConfirmed === false ?
|
||||||
|
<NavbarItem>
|
||||||
|
<small className="text-warning">Please confirm your email</small>
|
||||||
|
</NavbarItem>
|
||||||
|
:
|
||||||
|
""
|
||||||
|
}
|
||||||
<NavbarItem>
|
<NavbarItem>
|
||||||
<ProfileMenu/>
|
<ProfileMenu/>
|
||||||
</NavbarItem>
|
</NavbarItem>
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ class SetupDataLoader(
|
|||||||
val superadmin = User(
|
val superadmin = User(
|
||||||
username = "admin",
|
username = "admin",
|
||||||
password = "admin",
|
password = "admin",
|
||||||
email = "admin@gameyfin.org"
|
email = "admin@gameyfin.org",
|
||||||
|
emailConfirmed = true,
|
||||||
|
enabled = true
|
||||||
)
|
)
|
||||||
|
|
||||||
registerUserIfNotFound(superadmin, Roles.SUPERADMIN)
|
registerUserIfNotFound(superadmin, Roles.SUPERADMIN)
|
||||||
@@ -78,7 +80,9 @@ class SetupDataLoader(
|
|||||||
val user = User(
|
val user = User(
|
||||||
username = "user",
|
username = "user",
|
||||||
password = "user",
|
password = "user",
|
||||||
email = "user@gameyfin.org"
|
email = "user@gameyfin.org",
|
||||||
|
emailConfirmed = true,
|
||||||
|
enabled = true
|
||||||
)
|
)
|
||||||
|
|
||||||
registerUserIfNotFound(user, Roles.USER)
|
registerUserIfNotFound(user, Roles.USER)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.core.events
|
package de.grimsi.gameyfin.core.events
|
||||||
|
|
||||||
import de.grimsi.gameyfin.shared.token.Token
|
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.shared.token.TokenType.PasswordReset
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
import org.springframework.context.ApplicationEvent
|
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 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) :
|
class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: User, val baseUrl: String) :
|
||||||
ApplicationEvent(source)
|
ApplicationEvent(source)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class SecurityConfig(
|
|||||||
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
|
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
|
||||||
auth.requestMatchers("/setup").permitAll()
|
auth.requestMatchers("/setup").permitAll()
|
||||||
.requestMatchers("/reset-password").permitAll()
|
.requestMatchers("/reset-password").permitAll()
|
||||||
|
.requestMatchers("/accept-invitation").permitAll()
|
||||||
.requestMatchers("/public/**").permitAll()
|
.requestMatchers("/public/**").permitAll()
|
||||||
.requestMatchers("/images/**").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
|
@Bean
|
||||||
@Conditional(SsoEnabledCondition::class)
|
@Conditional(SsoEnabledCondition::class)
|
||||||
fun clientRegistrationRepository(): ClientRegistrationRepository? {
|
fun clientRegistrationRepository(): ClientRegistrationRepository? {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package de.grimsi.gameyfin.messages
|
package de.grimsi.gameyfin.messages
|
||||||
|
|
||||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
import de.grimsi.gameyfin.core.events.*
|
||||||
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.messages.providers.AbstractMessageProvider
|
import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider
|
||||||
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
|
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
|
||||||
import de.grimsi.gameyfin.messages.templates.MessageTemplates
|
import de.grimsi.gameyfin.messages.templates.MessageTemplates
|
||||||
@@ -158,4 +155,25 @@ class MessageService(
|
|||||||
mapOf("username" to user.username, "passwordResetLink" to event.baseUrl)
|
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.core.security.EncryptionConverter
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
import jakarta.persistence.Convert
|
import jakarta.persistence.*
|
||||||
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.CreationTimestamp
|
||||||
import org.hibernate.annotations.Type
|
import org.hibernate.annotations.Type
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.UUID
|
import java.util.*
|
||||||
import kotlin.time.toJavaDuration
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -29,5 +25,6 @@ class Token<T : TokenType>(
|
|||||||
val createdOn: Instant? = null
|
val createdOn: Instant? = null
|
||||||
) {
|
) {
|
||||||
val expired: Boolean
|
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 de.grimsi.gameyfin.users.entities.User
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import jakarta.transaction.Transactional
|
||||||
|
|
||||||
abstract class TokenService<T : TokenType>(
|
abstract class TokenService<T : TokenType>(
|
||||||
private val type: T,
|
private val type: T,
|
||||||
@@ -10,6 +11,7 @@ abstract class TokenService<T : TokenType>(
|
|||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
open fun generate(user: User): Token<T> {
|
open fun generate(user: User): Token<T> {
|
||||||
val token = Token(
|
val token = Token(
|
||||||
user = user,
|
user = user,
|
||||||
@@ -24,7 +26,8 @@ abstract class TokenService<T : TokenType>(
|
|||||||
return tokenRepository.save(token)
|
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
|
val token = tokenRepository.findBySecret(secret) ?: return null
|
||||||
|
|
||||||
return if (token.type == type) {
|
return if (token.type == type) {
|
||||||
@@ -36,11 +39,17 @@ abstract class TokenService<T : TokenType>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun delete(token: Token<T>) {
|
@Transactional
|
||||||
tokenRepository.delete(token)
|
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
|
val token = tokenRepository.findBySecret(secret) ?: return TokenValidationResult.INVALID
|
||||||
return if (token.expired) TokenValidationResult.EXPIRED else TokenValidationResult.VALID
|
return if (token.expired) TokenValidationResult.EXPIRED else TokenValidationResult.VALID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ sealed class TokenType(
|
|||||||
val expiration: Duration
|
val expiration: Duration
|
||||||
) {
|
) {
|
||||||
data object PasswordReset : TokenType("password-reset", 15.minutes)
|
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)
|
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 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()
|
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 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.config.ConfigService
|
||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
import de.grimsi.gameyfin.core.Utils
|
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.RegistrationAttemptWithExistingEmailEvent
|
||||||
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
|
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
|
||||||
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
|
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
|
||||||
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
||||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||||
import de.grimsi.gameyfin.users.dto.UserUpdateDto
|
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.Avatar
|
||||||
import de.grimsi.gameyfin.users.entities.Role
|
import de.grimsi.gameyfin.users.entities.Role
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
@@ -39,6 +41,7 @@ class UserService(
|
|||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val roleService: RoleService,
|
private val roleService: RoleService,
|
||||||
private val sessionService: SessionService,
|
private val sessionService: SessionService,
|
||||||
|
private val emailConfirmationService: EmailConfirmationService,
|
||||||
private val config: ConfigService,
|
private val config: ConfigService,
|
||||||
private val eventPublisher: ApplicationEventPublisher
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
) : UserDetailsService {
|
) : UserDetailsService {
|
||||||
@@ -168,6 +171,11 @@ class UserService(
|
|||||||
} else {
|
} else {
|
||||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
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) {
|
fun updateUser(username: String, updates: UserUpdateDto) {
|
||||||
@@ -183,6 +191,8 @@ class UserService(
|
|||||||
updates.email?.let {
|
updates.email?.let {
|
||||||
user.email = it
|
user.email = it
|
||||||
user.emailConfirmed = false
|
user.emailConfirmed = false
|
||||||
|
val token = emailConfirmationService.generate(user)
|
||||||
|
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
|
||||||
}
|
}
|
||||||
|
|
||||||
userRepository.save(user)
|
userRepository.save(user)
|
||||||
|
|||||||
+28
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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 emailConfirmed: Boolean = false,
|
||||||
|
|
||||||
var enabled: Boolean = true,
|
var enabled: Boolean = false,
|
||||||
|
|
||||||
@Embedded
|
@Embedded
|
||||||
@Nullable
|
@Nullable
|
||||||
|
|||||||
+2
-1
@@ -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.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
import de.grimsi.gameyfin.shared.token.TokenDto
|
import de.grimsi.gameyfin.shared.token.TokenDto
|
||||||
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||||
|
import de.grimsi.gameyfin.users.UserService
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
+5
-3
@@ -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.Utils
|
||||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||||
import de.grimsi.gameyfin.messages.MessageService
|
import de.grimsi.gameyfin.messages.MessageService
|
||||||
import de.grimsi.gameyfin.shared.token.*
|
import de.grimsi.gameyfin.shared.token.*
|
||||||
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
|
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 de.grimsi.gameyfin.users.entities.User
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
@@ -89,8 +91,8 @@ class PasswordResetService(
|
|||||||
Thread.sleep(secureRandom.nextLong(1024))
|
Thread.sleep(secureRandom.nextLong(1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetPassword(token: String, newPassword: String): TokenValidationResult {
|
fun resetPassword(secret: String, newPassword: String): TokenValidationResult {
|
||||||
val passwordResetToken = get(token, PasswordReset)
|
val passwordResetToken = get(secret, PasswordReset)
|
||||||
?: return TokenValidationResult.INVALID
|
?: return TokenValidationResult.INVALID
|
||||||
|
|
||||||
if (passwordResetToken.expired) {
|
if (passwordResetToken.expired) {
|
||||||
+2
-1
@@ -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.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
|
import de.grimsi.gameyfin.users.UserService
|
||||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
Reference in New Issue
Block a user