Implement user invitation flow

This commit is contained in:
grimsi
2024-09-27 14:54:15 +02:00
parent f9a857c707
commit e8ebab9c62
20 changed files with 423 additions and 62 deletions
@@ -6,9 +6,12 @@ import {ConfigEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info} from "@phosphor-icons/react";
import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@nextui-org/react";
import InviteUserModal from "Frontend/components/general/InviteUserModal";
function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]);
const [autoRegisterNewUsers, setAutoRegisterNewUsers] = useState(true);
@@ -32,7 +35,15 @@ function UserManagementLayout({getConfig, formik}: any) {
isDisabled={!formik.values.users["sign-ups"].allow}/>
</div>
<Section title="Users"/>
<div className="flex flex-row items-baseline justify-between">
<h2 className={"text-xl font-bold mt-8 mb-1"}>Users</h2>
<Tooltip content="Invite new user">
<Button isIconOnly variant="faded" onPress={inviteUserModal.onOpen}>
<UserPlus/>
</Button>
</Tooltip>
</div>
<Divider className="mb-4"/>
{!autoRegisterNewUsers &&
<SmallInfoField className="mb-4 text-warning" icon={Info}
message="Automatic user registration for SSO users is disabled"/>
@@ -40,6 +51,7 @@ function UserManagementLayout({getConfig, formik}: any) {
<div className="grid grid-cols-300px gap-4">
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
</div>
<InviteUserModal isOpen={inviteUserModal.isOpen} onOpenChange={inviteUserModal.onOpenChange}/>
</div>
);
}
@@ -0,0 +1,65 @@
import React, {useEffect, useState} from "react";
import {Button, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {RegistrationEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
interface InviteUserModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function InviteUserModal({
isOpen,
onOpenChange
}: InviteUserModalProps) {
const [email, setEmail] = useState<string | null>();
const [error, setError] = useState<string | null>();
useEffect(() => {
setEmail(null);
setError(null);
}, []);
async function inviteUser(onClose: () => void) {
if (email === null) return;
if (await UserEndpoint.existsByMail(email)) {
setError("User with this email already exists");
return;
}
try {
await RegistrationEndpoint.createInvitation(email);
toast.success("Invitation has been sent");
onClose();
} catch (e) {
setError("Failed to create invitation");
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Invite a new user</ModalHeader>
<ModalBody>
<p>Enter the email address of the user you want to invite:</p>
<Input errorMessage={error} onChange={(e) => setEmail(e.target.value)} type="email"/>
{error && <small className="text-danger">{error}</small>}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="success" onPress={() => inviteUser(onClose)}
isDisabled={email === null || email === undefined || email.length < 1}>
Send invitation
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
+5 -1
View File
@@ -16,6 +16,7 @@ import {MessageManagement} from "Frontend/components/administration/MessageManag
import {LogManagement} from "Frontend/components/administration/LogManagement";
import PasswordResetView from "Frontend/views/PasswordResetView";
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
export const routes = protectRoutes([
{
@@ -56,12 +57,15 @@ export const routes = protectRoutes([
{
path: '/setup', element: <SetupView/>, handle: {requiresLogin: false}
},
{
path: '/accept-invitation', element: <InvitationRegistrationView/>, handle: {requiresLogin: false}
},
{
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
},
{
path: '/confirm-email', element: <EmailConfirmationView/>, handle: {requiresLogin: true}
}
},
],
}
]) as RouteObject[];
@@ -0,0 +1,109 @@
import {Button, Card, CardBody, CardHeader} from "@nextui-org/react";
import {useNavigate, useSearchParams} from "react-router-dom";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/Input";
import * as Yup from "yup";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import React, {useEffect, useState} from "react";
import {Warning} from "@phosphor-icons/react";
import {toast} from "sonner";
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
export default function InvitationRegistrationView() {
const [searchParams, setSearchParams] = useSearchParams();
const [token, setToken] = useState<string>();
const [email, setEmail] = useState<string>();
const navigate = useNavigate();
useEffect(() => {
let token = searchParams.get("token");
if (token) {
setToken(token);
RegistrationEndpoint.getInvitationRecipientEmail(token).then(setEmail);
}
}, [searchParams]);
async function register(values: any) {
let result = await RegistrationEndpoint.acceptInvitation(token, {
email: email,
username: values.username,
password: values.password
});
switch (result) {
case TokenValidationResult.VALID:
toast.success("Registration successful");
navigate("/", {replace: true});
break;
case TokenValidationResult.EXPIRED:
toast.error("Token is expired");
break;
case TokenValidationResult.INVALID:
default:
toast.error("Token is invalid");
break
}
}
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>
{token ?
<Formik
enableReinitialize={true}
initialValues={{
username: "",
email: email,
password: "",
passwordRepeat: ""
}}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
onSubmit={register}>
{(formik: { values: any; isSubmitting: any; isValid: boolean; }) => (
<Form>
<p className="text-xl text-center mb-8">Register a new account</p>
<Input label="Email" name="email" type="email" value={email} disabled/>
<Input label="Username" name="username" autoComplete="username"/>
<Input label="Password" name="password" type="password"
autoComplete="new-password"/>
<Input label="Password (repeat)" name="passwordRepeat" type="password"
autoComplete="new-password"/>
<Button type="submit" className="w-full mt-4" color="primary"
isDisabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting}>
{formik.isSubmitting ? "" : "Create account"}
</Button>
</Form>
)}
</Formik>
:
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/>
Invalid token
</p>
}
</CardBody>
</Card>
</div>
);
}
@@ -2,11 +2,13 @@ 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.Invitation
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import de.grimsi.gameyfin.users.entities.User
import org.springframework.context.ApplicationEvent
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
class UserInvitationEvent(source: Any, val token: Token<Invitation>, val baseUrl: String, val email: String) :
ApplicationEvent(source)
class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) : ApplicationEvent(source)
@@ -2,57 +2,14 @@ package de.grimsi.gameyfin.core.security
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
import java.util.*
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
@Converter
class EncryptionConverter : AttributeConverter<String, String> {
companion object {
private const val ALGORITHM = "AES"
private val SECRET_KEY: SecretKeySpec
init {
val base64Key = System.getenv("APP_KEY")
?: throw IllegalStateException("APP_KEY environment variable is not set or empty")
val decodedKey = Base64.getDecoder().decode(base64Key)
// Ensure the key length is valid for AES (128, 192, or 256 bits)
if (decodedKey.size !in listOf(16, 24, 32)) {
throw IllegalArgumentException("Invalid AES key length. Key must be 128, 192, or 256 bits.")
}
SECRET_KEY = SecretKeySpec(decodedKey, ALGORITHM)
}
}
override fun convertToDatabaseColumn(attribute: String?): String? {
return attribute?.let {
try {
val cipher = Cipher.getInstance(ALGORITHM).apply {
init(Cipher.ENCRYPT_MODE, SECRET_KEY)
}
val encryptedBytes = cipher.doFinal(it.toByteArray())
Base64.getEncoder().encodeToString(encryptedBytes)
} catch (e: Exception) {
throw RuntimeException("Error during encryption", e)
}
}
return attribute?.let { EncryptionUtils.encrypt(it) }
}
override fun convertToEntityAttribute(dbData: String?): String? {
return dbData?.let {
try {
val cipher = Cipher.getInstance(ALGORITHM).apply {
init(Cipher.DECRYPT_MODE, SECRET_KEY)
}
val decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(it))
String(decryptedBytes)
} catch (e: Exception) {
throw RuntimeException("Error during decryption", e)
}
}
return dbData?.let { EncryptionUtils.decrypt(it) }
}
}
@@ -0,0 +1,27 @@
package de.grimsi.gameyfin.core.security
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.persistence.AttributeConverter
import jakarta.persistence.Converter
@Converter
class EncryptionMapConverter : AttributeConverter<Map<String, String>, String> {
companion object {
private val objectMapper = ObjectMapper()
}
override fun convertToDatabaseColumn(attribute: Map<String, String>?): String? {
return attribute?.let {
val jsonString = objectMapper.writeValueAsString(it)
EncryptionUtils.encrypt(jsonString)
}
}
@Suppress("UNCHECKED_CAST")
override fun convertToEntityAttribute(dbData: String?): Map<String, String>? {
return dbData?.let {
val decryptedString = EncryptionUtils.decrypt(it)
objectMapper.readValue(decryptedString, Map::class.java) as Map<String, String>?
}
}
}
@@ -0,0 +1,42 @@
package de.grimsi.gameyfin.core.security
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
class EncryptionUtils {
companion object {
private const val ALGORITHM = "AES"
private val SECRET_KEY: SecretKeySpec
init {
val base64Key = System.getenv("APP_KEY")
?: throw IllegalStateException("APP_KEY environment variable is not set or empty")
val decodedKey = Base64.getDecoder().decode(base64Key)
// Ensure the key length is valid for AES (128, 192, or 256 bits)
if (decodedKey.size !in listOf(16, 24, 32)) {
throw IllegalArgumentException("Invalid AES key length. Key must be 128, 192, or 256 bits.")
}
SECRET_KEY = SecretKeySpec(decodedKey, ALGORITHM)
}
fun encrypt(value: String): String {
val cipher = Cipher.getInstance(ALGORITHM).apply {
init(Cipher.ENCRYPT_MODE, SECRET_KEY)
}
val encryptedBytes = cipher.doFinal(value.toByteArray())
return Base64.getEncoder().encodeToString(encryptedBytes)
}
fun decrypt(value: String): String {
val cipher = Cipher.getInstance(ALGORITHM).apply {
init(Cipher.DECRYPT_MODE, SECRET_KEY)
}
val decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(value))
return String(decryptedBytes)
}
}
}
@@ -89,10 +89,10 @@ class MessageService(
val token = event.token
val resetLink = event.baseUrl + "/reset-password?token=${token.secret}"
sendNotification(
token.user.email,
token.creator.email,
"[Gameyfin] Password Reset Request",
MessageTemplates.PasswordResetRequest,
mapOf("username" to token.user.username, "resetLink" to resetLink)
mapOf("username" to token.creator.username, "resetLink" to resetLink)
)
}
@@ -167,7 +167,7 @@ class MessageService(
log.info { "Sending email confirmation notification" }
val user = event.token.user
val user = event.token.creator
val confirmationLink = event.baseUrl + "/confirm-email?token=${event.token.secret}"
sendNotification(
user.email,
@@ -176,4 +176,24 @@ class MessageService(
mapOf("username" to user.username, "confirmationLink" to confirmationLink)
)
}
@Async
@EventListener(UserInvitationEvent::class)
fun onUserInvitation(event: UserInvitationEvent) {
if (!enabled) {
log.error { "No notification provider available, can't send invitation message" }
return
}
log.info { "Sending invitation notification" }
val invitationLink = event.baseUrl + "/accept-invitation?token=${event.token.secret}"
sendNotification(
event.email,
"[Gameyfin] You've been invited!",
MessageTemplates.UserInvitation,
mapOf("invitationLink" to invitationLink)
)
}
}
@@ -1,9 +1,12 @@
package de.grimsi.gameyfin.shared.token
import de.grimsi.gameyfin.core.security.EncryptionConverter
import de.grimsi.gameyfin.core.security.EncryptionMapConverter
import de.grimsi.gameyfin.users.entities.User
import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.hibernate.annotations.Type
import java.time.Instant
import java.util.*
@@ -18,8 +21,12 @@ class Token<T : TokenType>(
@Type(TokenTypeUserType::class)
val type: T,
@OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
val user: User,
@ManyToOne(targetEntity = User::class, fetch = FetchType.EAGER)
@OnDelete(action = OnDeleteAction.CASCADE)
val creator: User,
@Convert(converter = EncryptionMapConverter::class)
val payload: Map<String, String> = emptyMap(),
@CreationTimestamp
val createdOn: Instant? = null
@@ -6,11 +6,15 @@ import kotlin.time.toJavaDuration
data class TokenDto(
val secret: String,
val type: String,
val expiresAt: Instant
val expiresAt: String
) {
constructor(token: Token<*>) : this(
secret = token.secret,
type = token.type.key,
expiresAt = token.createdOn?.plus(token.type.expiration.toJavaDuration()) ?: Instant.MIN
expiresAt = if (token.type.expiration.isFinite()) {
token.createdOn?.plus(token.type.expiration.toJavaDuration())?.toString() ?: Instant.MIN.toString()
} else {
"never"
}
)
}
@@ -5,5 +5,6 @@ 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>?
fun <T : TokenType> findByCreatorAndType(creator: User, type: T): Token<T>?
fun <T : TokenType> findByCreatorAndTypeAndPayload(creator: User, type: T, payload: Map<String, String>): Token<T>?
}
@@ -14,11 +14,11 @@ abstract class TokenService<T : TokenType>(
@Transactional
open fun generate(user: User): Token<T> {
val token = Token(
user = user,
creator = user,
type = type
)
tokenRepository.findByUserAndType(user, type)?.let {
tokenRepository.findByCreatorAndType(user, type)?.let {
log.warn { "Deleting existing ${it.type.key} token for user '${user.username}'" }
delete(it)
}
@@ -26,6 +26,22 @@ abstract class TokenService<T : TokenType>(
return tokenRepository.save(token)
}
@Transactional
open fun generateWithPayload(user: User, payload: Map<String, String>): Token<T> {
val token = Token(
creator = user,
type = type,
payload = payload
)
tokenRepository.findByCreatorAndTypeAndPayload(user, type, payload)?.let {
log.warn { "Deleting existing ${it.type.key} token of user '${user.username}' with same payload" }
delete(it)
}
return tokenRepository.save(token)
}
@Transactional
open fun get(secret: String, type: T): Token<T>? {
val token = tokenRepository.findBySecret(secret) ?: return null
@@ -39,6 +55,11 @@ abstract class TokenService<T : TokenType>(
}
}
@Transactional
open fun getPayload(secret: String): Map<String, String>? {
return tokenRepository.findBySecret(secret)?.payload
}
@Transactional
open fun delete(token: Token<T>) {
try {
@@ -14,6 +14,11 @@ import org.springframework.security.core.context.SecurityContextHolder
class UserEndpoint(
private val userService: UserService
) {
@PermitAll
fun existsByMail(email: String): Boolean {
return userService.existsByEmail(email)
}
@PermitAll
fun getUserInfo(): UserInfoDto {
val auth: Authentication = SecurityContextHolder.getContext().authentication
@@ -66,6 +66,7 @@ class UserService(
}
fun existsByUsername(username: String): Boolean = userRepository.existsByUsername(username)
fun existsByEmail(email: String): Boolean = userRepository.existsByEmail(email)
fun findByOidcProviderId(oidcProviderId: String): User? = userRepository.findByOidcProviderId(oidcProviderId)
@@ -178,6 +179,19 @@ class UserService(
}
}
fun registerUserFromInvitation(user: UserRegistrationDto, email: String): User {
val user = User(
username = user.username,
password = passwordEncoder.encode(user.password),
email = email,
emailConfirmed = true,
enabled = true,
roles = roleService.toRoles(listOf(Roles.USER))
)
return userRepository.save(user)
}
fun updateUser(username: String, updates: UserUpdateDto) {
val user = userByUsername(username)
@@ -30,7 +30,7 @@ class EmailConfirmationService(
return TokenValidationResult.EXPIRED
}
val user = emailConfirmationToken.user
val user = emailConfirmationToken.creator
confirmEmail(user)
delete(emailConfirmationToken)
@@ -99,7 +99,7 @@ class PasswordResetService(
return TokenValidationResult.EXPIRED
}
val user = passwordResetToken.user
val user = passwordResetToken.creator
userService.updatePassword(user, newPassword)
delete(passwordResetToken)
@@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository
interface UserRepository : JpaRepository<User, Long> {
fun existsByUsername(userName: String): Boolean
fun existsByEmail(email: String): Boolean
fun findByUsername(userName: String): User?
fun findByEmail(email: String): User?
fun findByOidcProviderId(oidcProviderId: String): User?
@@ -0,0 +1,54 @@
package de.grimsi.gameyfin.users.registration
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.UserInvitationEvent
import de.grimsi.gameyfin.shared.token.TokenDto
import de.grimsi.gameyfin.shared.token.TokenRepository
import de.grimsi.gameyfin.shared.token.TokenService
import de.grimsi.gameyfin.shared.token.TokenType.Invitation
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service
@Service
class InvitationService(
tokenRepository: TokenRepository,
private val userService: UserService,
private val eventPublisher: ApplicationEventPublisher
) : TokenService<Invitation>(Invitation, tokenRepository) {
companion object {
private const val EMAIL_KEY = "email"
}
fun createInvitation(email: String): TokenDto {
if (userService.existsByEmail(email))
throw IllegalStateException("User with email ${Utils.maskEmail(email)} is already registered")
val auth: Authentication = SecurityContextHolder.getContext().authentication
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
val payload = mapOf(EMAIL_KEY to email)
val token = super.generateWithPayload(user, payload)
eventPublisher.publishEvent(UserInvitationEvent(this, token, Utils.getBaseUrl(), email))
return TokenDto(token)
}
fun getAssociatedEmail(secret: String): String? {
val payload = super.getPayload(secret) ?: return null
return payload[EMAIL_KEY]
}
fun acceptInvitation(secret: String, registration: UserRegistrationDto): TokenValidationResult {
val invitationToken = super.get(secret, Invitation) ?: return TokenValidationResult.INVALID
val email = invitationToken.payload[EMAIL_KEY] ?: return TokenValidationResult.INVALID
if (invitationToken.expired) return TokenValidationResult.EXPIRED
userService.registerUserFromInvitation(registration, email)
return TokenValidationResult.VALID
}
}
@@ -3,6 +3,8 @@ 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.shared.token.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import jakarta.annotation.security.RolesAllowed
@@ -10,7 +12,8 @@ import jakarta.annotation.security.RolesAllowed
@AnonymousAllowed
@Endpoint
class RegistrationEndpoint(
private val userService: UserService
private val userService: UserService,
private val invitationService: InvitationService
) {
fun isSelfRegistrationAllowed(): Boolean {
return userService.selfRegistrationAllowed
@@ -26,8 +29,21 @@ class RegistrationEndpoint(
return !userService.existsByUsername(username)
}
fun acceptInvitation(token: String, registration: UserRegistrationDto): TokenValidationResult {
return invitationService.acceptInvitation(token, registration)
}
fun getInvitationRecipientEmail(token: String): String? {
return invitationService.getAssociatedEmail(token)
}
@RolesAllowed(Roles.Names.ADMIN)
fun confirmRegistration(username: String) {
userService.confirmRegistration(username)
}
@RolesAllowed(Roles.Names.ADMIN)
fun createInvitation(email: String): TokenDto {
return invitationService.createInvitation(email)
}
}