Implemented profile management

Refactored shared code into components
Added parallel session handling
This commit is contained in:
grimsi
2024-09-12 18:01:47 +02:00
parent 1273714b8d
commit 0fe91a1980
15 changed files with 275 additions and 27 deletions
@@ -1,11 +1,112 @@
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/Input";
import {Form, Formik} from "formik";
import {Button} from "@nextui-org/react";
import {Check, Info} 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 {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {toast} from "sonner";
export default function ProfileManagement() { export default function ProfileManagement() {
const [configSaved, setConfigSaved] = useState(false);
const auth = useAuth();
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
async function handleSubmit(values: any) {
const userUpdate: UserUpdateDto = {
username: values.username,
email: values.email
}
if (values.newPassword.length > 0) {
userUpdate.password = values.newPassword;
}
await UserEndpoint.updateUser(userUpdate);
setConfigSaved(true);
if (values.newPassword.length > 0) {
toast.success("Password changed", {
description: "Please log in again"
});
setTimeout(() => {
auth.logout();
}, 500);
}
}
return ( return (
<div className="flex flex-col"> <>
<h2 className="text-2xl font-bold">My Profile</h2> <Formik
<Section title="Personal information"/> initialValues={{
{/* TODO */} username: auth.state.user?.username,
</div> email: auth.state.user?.email,
newPassword: "",
passwordRepeat: ""
}}
onSubmit={handleSubmit}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
newPassword: Yup.string()
.min(8, 'Password must be at least 8 characters long'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('newPassword')], 'Passwords do not match')
})}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2>
<div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info}
message="You will be logged out of all current sessions"
className="text-foreground/70"
/>
}
<Button
className="button-secondary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting || configSaved}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<div className="flex flex-row flex-1 justify-between gap-8">
<div className="flex flex-col flex-grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"/>
<Input name="email" label="Email" type="email" autocomplete="email"/>
</div>
<div className="flex flex-col flex-1">
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password"/>
<Input name="passwordRepeat" label="Repeat password" type="password"
autocomplete="new-password"/>
</div>
</div>
</Form>
)}
</Formik>
</>
); );
} }
@@ -1,6 +1,7 @@
import {useField} from "formik"; import {useField} from "formik";
import {XCircle} from "@phosphor-icons/react";
import {Input as NextUiInput} from "@nextui-org/react"; import {Input as NextUiInput} from "@nextui-org/react";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {XCircle} from "@phosphor-icons/react";
// @ts-ignore // @ts-ignore
const Input = ({label, ...props}) => { const Input = ({label, ...props}) => {
@@ -8,18 +9,19 @@ const Input = ({label, ...props}) => {
const [field, meta] = useField(props); const [field, meta] = useField(props);
return ( return (
<div className="flex flex-grow max-w-sm items-center gap-2 my-2"> <div className="flex flex-col flex-grow max-w-sm items-start gap-2 my-2">
<NextUiInput <NextUiInput
{...props} {...props}
{...field} {...field}
id={label} id={label}
label={label} label={label}
isInvalid={meta.touched && !!meta.error} isInvalid={meta.touched && !!meta.error}
errorMessage={
<small className="flex flex-row items-center gap-1 text-danger">
<XCircle weight="fill" size={14}/> {meta.error}
</small>}
/> />
<div className="min-h-6 w-full text-danger">
{meta.touched && meta.error && (
<SmallInfoField icon={XCircle} message={meta.error}/>
)}
</div>
</div> </div>
); );
} }
@@ -0,0 +1,12 @@
import React from 'react';
// @ts-ignore
export function SmallInfoField({icon: IconComponent, message, ...props}) {
return (
<div {...props}>
<small className="flex flex-row items-center gap-1">
<IconComponent weight="fill" size={14}/> {message}
</small>
</div>
);
}
@@ -5,9 +5,9 @@ import jakarta.annotation.Nonnull
@JsonInclude(JsonInclude.Include.ALWAYS) @JsonInclude(JsonInclude.Include.ALWAYS)
data class ConfigEntryDto( data class ConfigEntryDto(
@Nonnull val key: String, @field:Nonnull val key: String,
val value: String?, val value: String?,
val defaultValue: String?, val defaultValue: String?,
@Nonnull val type: String, @field:Nonnull val type: String,
@Nonnull val description: String @field:Nonnull val description: String
) )
@@ -8,16 +8,22 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.session.SessionRegistryImpl
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder
@EnableWebSecurity
@Configuration @Configuration
@EnableWebSecurity
class SecurityConfig( class SecurityConfig(
private val environment: Environment private val environment: Environment
) : VaadinWebSecurity() { ) : VaadinWebSecurity() {
@Bean
fun sessionRegistry(): SessionRegistry {
return SessionRegistryImpl()
}
@Throws(Exception::class) @Throws(Exception::class)
override fun configure(http: HttpSecurity) { override fun configure(http: HttpSecurity) {
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher // Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
@@ -26,6 +32,12 @@ class SecurityConfig(
.requestMatchers("/public/**").permitAll() .requestMatchers("/public/**").permitAll()
} }
http.sessionManagement { sessionManagement ->
sessionManagement
.maximumSessions(3)
.sessionRegistry(sessionRegistry())
}
super.configure(http) super.configure(http)
setLoginView(http, "/login") setLoginView(http, "/login")
} }
@@ -0,0 +1,15 @@
package de.grimsi.gameyfin.meta.annotations
import jakarta.validation.Constraint
import jakarta.validation.Payload
import kotlin.reflect.KClass
@MustBeDocumented
@Constraint(validatedBy = [NullOrNotBlankValidator::class])
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
annotation class NullOrNotBlank(
val message: String = "must be null or not blank",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
@@ -0,0 +1,11 @@
package de.grimsi.gameyfin.meta.annotations
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
class NullOrNotBlankValidator : ConstraintValidator<NullOrNotBlank, String?> {
override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
return value == null || value.isNotBlank()
}
}
@@ -66,14 +66,16 @@ class SetupDataLoader(
val superadmin = User( val superadmin = User(
username = "admin", username = "admin",
password = "admin" password = "admin",
email = "admin@gameyfin.org"
) )
registerUserIfNotFound(superadmin, Roles.SUPERADMIN) registerUserIfNotFound(superadmin, Roles.SUPERADMIN)
val user = User( val user = User(
username = "user", username = "user",
password = "user" password = "user",
email = "user@gameyfin.org"
) )
registerUserIfNotFound(user, Roles.USER) registerUserIfNotFound(user, Roles.USER)
@@ -0,0 +1,22 @@
package de.grimsi.gameyfin.users
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.session.SessionInformation
import org.springframework.security.core.session.SessionRegistry
import org.springframework.stereotype.Service
@Service
class SessionService(private val sessionRegistry: SessionRegistry) {
fun logoutAllSessions() {
val auth: Authentication? = SecurityContextHolder.getContext().authentication
if (auth != null) {
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth.principal, false)
for (sessionInfo in sessions) {
sessionInfo.expireNow()
}
SecurityContextHolder.clearContext()
}
}
}
@@ -4,10 +4,11 @@ import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.meta.Roles import de.grimsi.gameyfin.meta.Roles
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.entities.User import de.grimsi.gameyfin.users.entities.User
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@Endpoint @Endpoint
@@ -18,8 +19,7 @@ class UserEndpoint(
@PermitAll @PermitAll
fun getUserInfo(): UserInfoDto { fun getUserInfo(): UserInfoDto {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth: Authentication = SecurityContextHolder.getContext().authentication
val authorities: List<String> = auth.authorities.map { g: GrantedAuthority -> g.authority } return userService.getUserInfo(auth.name)
return UserInfoDto(username = auth.name, roles = authorities)
} }
@PermitAll @PermitAll
@@ -28,6 +28,28 @@ class UserEndpoint(
return userService.toUserInfo(user) return userService.toUserInfo(user)
} }
@PermitAll
fun updateUser(updates: UserUpdateDto) {
val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.updateUser(auth.name, updates)
}
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
fun updateUser(username: String, updates: UserUpdateDto) {
userService.updateUser(username, updates)
}
@PermitAll
fun deleteUser() {
val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.deleteUser(auth.name)
}
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
fun deleteUser(username: String) {
userService.deleteUser(username)
}
private fun registerUser(registration: UserRegistrationDto, roles: List<Roles>): User { private fun registerUser(registration: UserRegistrationDto, roles: List<Roles>): User {
val user = User( val user = User(
username = registration.username, username = registration.username,
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.meta.Roles import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.dto.UserInfoDto import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto
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
import de.grimsi.gameyfin.users.persistence.UserRepository import de.grimsi.gameyfin.users.persistence.UserRepository
@@ -20,12 +21,12 @@ import org.springframework.stereotype.Service
class UserService( class UserService(
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val roleService: RoleService private val roleService: RoleService,
private val sessionService: SessionService
) : UserDetailsService { ) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails { override fun loadUserByUsername(username: String): UserDetails {
val user = val user = userByUsername(username)
userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
return org.springframework.security.core.userdetails.User( return org.springframework.security.core.userdetails.User(
user.username, user.username,
@@ -33,13 +34,18 @@ class UserService(
user.enabled, user.enabled,
true, true,
true, true,
user.enabled, true,
toAuthorities(user.roles) toAuthorities(user.roles)
) )
} }
fun existsByUsername(username: String): Boolean = userRepository.findByUsername(username) != null fun existsByUsername(username: String): Boolean = userRepository.findByUsername(username) != null
fun getUserInfo(username: String): UserInfoDto {
val user = userByUsername(username)
return toUserInfo(user)
}
fun registerUser(user: User, role: Roles): User { fun registerUser(user: User, role: Roles): User {
return registerUser(user, listOf(role)) return registerUser(user, listOf(role))
} }
@@ -50,6 +56,26 @@ class UserService(
return userRepository.save(user) return userRepository.save(user)
} }
fun updateUser(username: String, updates: UserUpdateDto) {
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) {
sessionService.logoutAllSessions()
}
}
fun deleteUser(username: String) {
val user = userByUsername(username)
userRepository.delete(user)
}
fun toUserInfo(user: User): UserInfoDto { fun toUserInfo(user: User): UserInfoDto {
return UserInfoDto( return UserInfoDto(
username = user.username, username = user.username,
@@ -61,4 +87,8 @@ class UserService(
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> { private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.rolename) } return roles.map { r -> SimpleGrantedAuthority(r.rolename) }
} }
private fun userByUsername(username: String): User {
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
}
} }
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.users.dto
data class UserDetailsDto(
val id: Long,
val username: String,
val email: String
)
@@ -2,6 +2,6 @@ package de.grimsi.gameyfin.users.dto
data class UserInfoDto( data class UserInfoDto(
val username: String, val username: String,
val email: String? = null, val email: String,
val roles: List<String> val roles: List<String>
) )
@@ -0,0 +1,12 @@
package de.grimsi.gameyfin.users.dto
import de.grimsi.gameyfin.meta.annotations.NullOrNotBlank
data class UserUpdateDto(
@field:NullOrNotBlank
val username: String?,
@field:NullOrNotBlank
val password: String?,
@field:NullOrNotBlank
val email: String?
)
@@ -21,7 +21,7 @@ class User(
@Nullable @Nullable
@Column(unique = true) @Column(unique = true)
var email: String? = null, var email: String,
var email_confirmed: Boolean = false, var email_confirmed: Boolean = false,