From ef32c82712684f69d7452b08251997f662a08d0c Mon Sep 17 00:00:00 2001 From: Brutus5000 Date: Thu, 10 Aug 2023 18:15:02 +0200 Subject: [PATCH] UCP WIP --- .../userservice/backend/domain/User.kt | 3 +- .../userservice/backend/hydra/HydraService.kt | 4 +- .../backend/metrics/MetricHelper.kt | 3 +- .../registration/RegistrationService.kt | 2 +- .../backend/security/CurrentUserService.kt | 30 ++++ .../{login => security}/LoginService.kt | 33 +--- .../backend/security/PasswordService.kt | 56 ++++++ .../userservice/config/FafProperties.kt | 20 +++ .../userservice/ui/layout/UcpLayout.kt | 80 +++++++++ .../userservice/ui/view/oauth2/LoginView.kt | 2 +- .../ui/view/ucp/AccountDataView.kt | 169 ++++++++++++++++++ .../backend/domain/LoginServiceTest.kt | 8 +- .../backend/hydra/HydraServiceTest.kt | 4 +- 13 files changed, 375 insertions(+), 39 deletions(-) create mode 100644 src/main/kotlin/com/faforever/userservice/backend/security/CurrentUserService.kt rename src/main/kotlin/com/faforever/userservice/backend/{login => security}/LoginService.kt (84%) create mode 100644 src/main/kotlin/com/faforever/userservice/backend/security/PasswordService.kt create mode 100644 src/main/kotlin/com/faforever/userservice/ui/layout/UcpLayout.kt create mode 100644 src/main/kotlin/com/faforever/userservice/ui/view/ucp/AccountDataView.kt diff --git a/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt b/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt index 747549a9..62cf923d 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt @@ -19,7 +19,8 @@ data class User( val id: Int? = null, @Column(name = "login") val username: String, - val password: String, + @Column(name = "password") + val passwordHash: String, val email: String, val ip: String?, ) : PanacheEntityBase { diff --git a/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt b/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt index 351163d5..90a68a80 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt @@ -2,8 +2,8 @@ package com.faforever.userservice.backend.hydra import com.faforever.userservice.backend.domain.IpAddress import com.faforever.userservice.backend.domain.UserRepository -import com.faforever.userservice.backend.login.LoginResult -import com.faforever.userservice.backend.login.LoginService +import com.faforever.userservice.backend.security.LoginResult +import com.faforever.userservice.backend.security.LoginService import com.faforever.userservice.backend.security.OAuthScope import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.inject.Produces diff --git a/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt b/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt index cfb036c9..ea778f96 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt @@ -40,8 +40,9 @@ class MetricHelper(meterRegistry: MeterRegistry) { "steamLinkFailed", ) - // Username Change Counters + // User Change Counters val userNameChangeCounter: Counter = meterRegistry.counter("user.name.change.count") + val userPasswordChangeCounter: Counter = meterRegistry.counter("user.password.change.count") // Password Reset Counters val userPasswordResetRequestCounter: Counter = meterRegistry.counter( diff --git a/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt b/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt index 6c02f242..55da5d0f 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt @@ -138,7 +138,7 @@ class RegistrationService( val user = User( username = username, - password = encodedPassword, + passwordHash = encodedPassword, email = email, ip = ipAddress.value, ) diff --git a/src/main/kotlin/com/faforever/userservice/backend/security/CurrentUserService.kt b/src/main/kotlin/com/faforever/userservice/backend/security/CurrentUserService.kt new file mode 100644 index 00000000..7f5da3d6 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/security/CurrentUserService.kt @@ -0,0 +1,30 @@ +package com.faforever.userservice.backend.security + +import com.faforever.userservice.backend.domain.User +import io.quarkus.security.identity.SecurityIdentity +import jakarta.inject.Singleton +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@Singleton +class CurrentUserService( + private val securityIdentity: SecurityIdentity, +) { + companion object { + private val log: Logger = LoggerFactory.getLogger(CurrentUserService::class.java) + } + + // TODO: Implement Vaadin auth mechanism and reload from database or something + fun requireUser(): User = User( + 5, + "zep", + "thisshouldnotbehere", + email = "iam@faforever.com", + null, + ) + + fun invalidate() { + log.debug("Invalidating current user") + // TODO: Invalidate cache + } +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt b/src/main/kotlin/com/faforever/userservice/backend/security/LoginService.kt similarity index 84% rename from src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt rename to src/main/kotlin/com/faforever/userservice/backend/security/LoginService.kt index 00c8c56a..f3c9a42a 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/security/LoginService.kt @@ -1,4 +1,4 @@ -package com.faforever.userservice.backend.login +package com.faforever.userservice.backend.security import com.faforever.userservice.backend.domain.AccountLinkRepository import com.faforever.userservice.backend.domain.Ban @@ -9,30 +9,13 @@ import com.faforever.userservice.backend.domain.LoginLog import com.faforever.userservice.backend.domain.LoginLogRepository import com.faforever.userservice.backend.domain.User import com.faforever.userservice.backend.domain.UserRepository -import com.faforever.userservice.backend.security.PasswordEncoder -import io.smallrye.config.ConfigMapping +import com.faforever.userservice.config.FafProperties import jakarta.enterprise.context.ApplicationScoped -import jakarta.validation.constraints.NotNull import org.slf4j.Logger import org.slf4j.LoggerFactory import java.time.LocalDateTime import java.time.OffsetDateTime -@ConfigMapping(prefix = "security") -interface SecurityProperties { - @NotNull - fun failedLoginAccountThreshold(): Int - - @NotNull - fun failedLoginAttemptThreshold(): Int - - @NotNull - fun failedLoginThrottlingMinutes(): Long - - @NotNull - fun failedLoginDaysToCheck(): Long -} - sealed interface LoginResult { sealed interface RecoverableLoginFailure : LoginResult object ThrottlingActive : RecoverableLoginFailure @@ -60,7 +43,7 @@ interface LoginService { @ApplicationScoped class LoginServiceImpl( - private val securityProperties: SecurityProperties, + private val fafProperties: FafProperties, private val userRepository: UserRepository, private val loginLogRepository: LoginLogRepository, private val accountLinkRepository: AccountLinkRepository, @@ -80,7 +63,7 @@ class LoginServiceImpl( } val user = userRepository.findByUsernameOrEmail(usernameOrEmail) - if (user == null || !passwordEncoder.matches(password, user.password)) { + if (user == null || !passwordEncoder.matches(password, user.passwordHash)) { logFailedLogin(usernameOrEmail, ip) return LoginResult.RecoverableLoginOrCredentialsMismatch } @@ -119,7 +102,7 @@ class LoginServiceImpl( private fun throttlingRequired(ip: IpAddress): Boolean { val failedAttemptsSummary = loginLogRepository.findFailedAttemptsByIpAfterDate( ip.value, - LocalDateTime.now().minusDays(securityProperties.failedLoginDaysToCheck()), + LocalDateTime.now().minusDays(fafProperties.security().failedLoginDaysToCheck()), ) ?: FailedAttemptsSummary(0, 0, null, null) val accountsAffected = failedAttemptsSummary.accountsAffected @@ -127,12 +110,12 @@ class LoginServiceImpl( LOG.debug("Failed login attempts for IP address '{}': {}", ip, failedAttemptsSummary) - return if (accountsAffected > securityProperties.failedLoginAccountThreshold() || - totalFailedAttempts > securityProperties.failedLoginAttemptThreshold() + return if (accountsAffected > fafProperties.security().failedLoginAccountThreshold() || + totalFailedAttempts > fafProperties.security().failedLoginAttemptThreshold() ) { val lastAttempt = failedAttemptsSummary.lastAttemptAt!! if (LocalDateTime.now() - .minusMinutes(securityProperties.failedLoginThrottlingMinutes()) + .minusMinutes(fafProperties.security().failedLoginThrottlingMinutes()) .isBefore(lastAttempt) ) { LOG.debug("IP '$ip' is trying again to early -> throttle it") diff --git a/src/main/kotlin/com/faforever/userservice/backend/security/PasswordService.kt b/src/main/kotlin/com/faforever/userservice/backend/security/PasswordService.kt new file mode 100644 index 00000000..70a8d935 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/security/PasswordService.kt @@ -0,0 +1,56 @@ +package com.faforever.userservice.backend.security + +import com.faforever.userservice.backend.domain.User +import com.faforever.userservice.backend.domain.UserRepository +import com.faforever.userservice.backend.metrics.MetricHelper +import com.faforever.userservice.config.FafProperties +import jakarta.enterprise.context.ApplicationScoped +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@ApplicationScoped +class PasswordService( + private val fafProperties: FafProperties, + private val metricHelper: MetricHelper, + private val userRepository: UserRepository, + private val passwordEncoder: PasswordEncoder, +) { + + companion object { + private val log: Logger = LoggerFactory.getLogger(FafTokenService::class.java) + } + + enum class ValidatePasswordResult { + VALID, + TOO_SHORT, + } + + fun validatePassword(password: String) = + if (password.length < fafProperties.security().minimumPasswordLength()) { + ValidatePasswordResult.TOO_SHORT + } else { + ValidatePasswordResult.VALID + } + + enum class ChangePasswordResult { + OK, + PASSWORD_MISMATCH, + } + + fun changePassword(user: User, oldPassword: String, newPassword: String): ChangePasswordResult { + if (!passwordEncoder.matches(oldPassword, user.passwordHash)) { + return ChangePasswordResult.PASSWORD_MISMATCH + } + + userRepository.persist( + user.copy( + passwordHash = passwordEncoder.encode(newPassword), + ), + ) + metricHelper.userPasswordChangeCounter.increment() + + log.info("Password of user ${user.id} was changed") + + return ChangePasswordResult.OK + } +} diff --git a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt index 9a6ce930..f0c469d0 100644 --- a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt +++ b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt @@ -3,6 +3,7 @@ package com.faforever.userservice.config import io.smallrye.config.ConfigMapping import io.smallrye.config.WithDefault import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull import java.util.* @ConfigMapping(prefix = "faf") @@ -16,12 +17,31 @@ interface FafProperties { @NotBlank fun realIpHeader(): String + fun security(): Security + fun account(): Account fun jwt(): Jwt fun recaptcha(): Recaptcha + interface Security { + @WithDefault("6") + fun minimumPasswordLength(): Int + + @NotNull + fun failedLoginAccountThreshold(): Int + + @NotNull + fun failedLoginAttemptThreshold(): Int + + @NotNull + fun failedLoginThrottlingMinutes(): Long + + @NotNull + fun failedLoginDaysToCheck(): Long + } + interface Jwt { fun secret(): String } diff --git a/src/main/kotlin/com/faforever/userservice/ui/layout/UcpLayout.kt b/src/main/kotlin/com/faforever/userservice/ui/layout/UcpLayout.kt new file mode 100644 index 00000000..fba616d4 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/layout/UcpLayout.kt @@ -0,0 +1,80 @@ +package com.faforever.userservice.ui.layout + +import com.faforever.userservice.backend.i18n.I18n +import com.faforever.userservice.ui.view.ucp.AccountDataView +import com.vaadin.flow.component.Component +import com.vaadin.flow.component.applayout.AppLayout +import com.vaadin.flow.component.applayout.DrawerToggle +import com.vaadin.flow.component.html.Anchor +import com.vaadin.flow.component.html.H1 +import com.vaadin.flow.component.html.Span +import com.vaadin.flow.component.icon.Icon +import com.vaadin.flow.component.icon.VaadinIcon +import com.vaadin.flow.component.tabs.Tab +import com.vaadin.flow.component.tabs.Tabs +import com.vaadin.flow.router.RouterLayout +import com.vaadin.flow.router.RouterLink +import kotlin.reflect.KClass + +abstract class UcpLayout( + private val i18n: I18n, +) : AppLayout(), RouterLayout { + + init { + val toggle = DrawerToggle() + + val title = H1("FAF User Control Panel") + title.style.set("font-size", "var(--lumo-font-size-l)")["margin"] = "0" + + // createLinks().forEach(::addToDrawer) + addToDrawer(getTabs()) + addToNavbar(toggle, title) + } + + private fun buildAnchor(href: String, i18nKey: String, icon: VaadinIcon) = + Anchor().apply { + setHref(href) + add(icon.create()) + // add(i18n.getTranslation(i18nKey)) + add(i18nKey) + } + + private fun getTabs(): Tabs { + val tabs = Tabs() + tabs.add( + createTab(VaadinIcon.USER_CARD, "Account Data", AccountDataView::class), + createTab(VaadinIcon.LINK, "Account Links", AccountDataView::class, false), + createTab(VaadinIcon.DESKTOP, "Active Devices", AccountDataView::class, false), + createTab(VaadinIcon.USER_HEART, "Friends & Foes", AccountDataView::class, false), + createTab(VaadinIcon.TROPHY, "Avatars", AccountDataView::class, false), + createTab(VaadinIcon.FILE_ZIP, "Uploaded content", AccountDataView::class, false), + createTab(VaadinIcon.SWORD, "Moderation Reports", AccountDataView::class, false), + createTab(VaadinIcon.KEY_O, "Permissions", AccountDataView::class, false), + createTab(VaadinIcon.BAN, "Ban history", AccountDataView::class, false), + createTab(VaadinIcon.EXIT_O, "Delete Account", AccountDataView::class, false), + ) + tabs.orientation = Tabs.Orientation.VERTICAL + return tabs + } + + private fun createTab( + viewIcon: VaadinIcon, + viewName: String, + route: KClass, + enabled: Boolean = true, + ): Tab { + val icon: Icon = viewIcon.create() + icon.getStyle().set("box-sizing", "border-box") + .set("margin-inline-end", "var(--lumo-space-m)") + .set("margin-inline-start", "var(--lumo-space-xs)") + .set("padding", "var(--lumo-space-xs)") + val link = RouterLink() + link.add(icon, Span(viewName)) + // Demo has no routes + link.setRoute(route.java) + link.tabIndex = -1 + return Tab(link).apply { + isEnabled = enabled + } + } +} diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt index ec761313..20dcf037 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt @@ -4,7 +4,7 @@ import com.faforever.userservice.backend.domain.IpAddress import com.faforever.userservice.backend.hydra.HydraService import com.faforever.userservice.backend.hydra.LoginResponse import com.faforever.userservice.backend.hydra.NoChallengeException -import com.faforever.userservice.backend.login.LoginResult +import com.faforever.userservice.backend.security.LoginResult import com.faforever.userservice.config.FafProperties import com.faforever.userservice.ui.component.FontAwesomeIcon import com.faforever.userservice.ui.component.LogoHeader diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/ucp/AccountDataView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/ucp/AccountDataView.kt new file mode 100644 index 00000000..825a263f --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/ucp/AccountDataView.kt @@ -0,0 +1,169 @@ +package com.faforever.userservice.ui.view.ucp + +import com.faforever.userservice.backend.email.EmailService +import com.faforever.userservice.backend.i18n.I18n +import com.faforever.userservice.backend.security.CurrentUserService +import com.faforever.userservice.backend.security.FafTokenService +import com.faforever.userservice.backend.security.PasswordService +import com.faforever.userservice.ui.layout.UcpLayout +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.html.Div +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.icon.VaadinIcon +import com.vaadin.flow.component.textfield.PasswordField +import com.vaadin.flow.component.textfield.TextField +import com.vaadin.flow.router.BeforeEnterEvent +import com.vaadin.flow.router.BeforeEnterObserver +import com.vaadin.flow.router.Route +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +@Route("/ucp") +class AccountDataView( + private val i18n: I18n, + private val currentUserService: CurrentUserService, + private val emailService: EmailService, + private val passwordService: PasswordService, +) : UcpLayout(i18n), BeforeEnterObserver { + + companion object { + private val log: Logger = LoggerFactory.getLogger(FafTokenService::class.java) + } + + private val usernameField = TextField().apply { + label = "Username" + prefixComponent = VaadinIcon.USER.create() + } + + private val usernameChangeButton = Button().apply { + text = "Change username" + prefixComponent = VaadinIcon.EDIT.create() + addClickListener { + changeUsername(usernameField.value) + } + } + + private val emailField = TextField().apply { + label = "Email" + prefixComponent = VaadinIcon.ENVELOPE_O.create() + } + + private val emailChangeButton = Button().apply { + text = "Change Email" + prefixComponent = VaadinIcon.EDIT.create() + addClickListener { + changeEmail(emailField.value) + } + } + + private val passwordField = PasswordField().apply { + label = "Password" + prefixComponent = VaadinIcon.PASSWORD.create() + } + + private val passwordChangeButton = Button().apply { + text = "Change Password" + prefixComponent = VaadinIcon.EDIT.create() + addClickListener { + changePassword("", passwordField.value) + } + } + + init { + content = Div().apply { + add(H2("Account Data")) + + add( + Div().apply { + add(usernameField) + add(usernameChangeButton) + }, + ) + + add( + Div().apply { + add(emailField) + add(emailChangeButton) + }, + ) + + add( + Div().apply { + add(passwordField) + add(passwordChangeButton) + }, + ) + } + } + + override fun beforeEnter(event: BeforeEnterEvent) { + val user = currentUserService.requireUser() + + usernameField.value = user.username + emailField.value = user.email + } + + private fun changeUsername(username: String) { + if (username.length > 20) { + usernameField.isInvalid = true + usernameField.errorMessage = "Username is too long" + + return + } + + usernameField.isInvalid = false + usernameField.value = username + } + + private fun changeEmail(email: String) { + when (emailService.validateEmailAddress(email)) { + EmailService.ValidationResult.INVALID -> { + log.debug("Mail address $email invalid") + emailField.isInvalid = true + emailField.errorMessage = "Mail address invalid" + return + } + EmailService.ValidationResult.BLACKLISTED -> { + log.debug("Email provider of $email is blacklisted") + emailField.isInvalid = true + emailField.errorMessage = "Email provider is blacklisted" + return + } + EmailService.ValidationResult.VALID -> { + val currentUser = currentUserService.requireUser() + log.info("Email address of user ${currentUser.id} changed from ${currentUser.email} to $email") + emailService.changeUserEmail(email, currentUserService.requireUser()) + emailField.isInvalid = false + emailField.errorMessage = null + emailField.value = email + } + } + } + + private fun changePassword(oldPassword: String, newPassword: String) { + when (passwordService.validatePassword(newPassword)) { + PasswordService.ValidatePasswordResult.TOO_SHORT -> { + log.debug("Password is too short") + passwordField.isInvalid = true + passwordField.errorMessage = "Password is too short" + return + } + PasswordService.ValidatePasswordResult.VALID -> log.debug("Password is valid") + } + + when (passwordService.changePassword(currentUserService.requireUser(), oldPassword, newPassword)) { + PasswordService.ChangePasswordResult.PASSWORD_MISMATCH -> { + log.debug("Old password did not match") + passwordField.isInvalid = true + passwordField.errorMessage = "Old password did not match" + } + PasswordService.ChangePasswordResult.OK -> { + log.debug("Password was changed") + currentUserService.invalidate() + passwordField.isInvalid = false + passwordField.errorMessage = null + passwordField.clear() + } + } + } +} diff --git a/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt index 740ad0af..0b413377 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt @@ -1,8 +1,7 @@ package com.faforever.userservice.backend.domain -import com.faforever.userservice.backend.login.LoginResult -import com.faforever.userservice.backend.login.LoginServiceImpl -import com.faforever.userservice.backend.login.SecurityProperties +import com.faforever.userservice.backend.security.LoginResult +import com.faforever.userservice.backend.security.LoginServiceImpl import com.faforever.userservice.backend.security.PasswordEncoder import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest @@ -32,9 +31,6 @@ class LoginServiceTest { @Inject private lateinit var loginService: LoginServiceImpl - @Inject - private lateinit var securityProperties: SecurityProperties - @InjectMock private lateinit var userRepository: UserRepository diff --git a/src/test/kotlin/com/faforever/userservice/backend/hydra/HydraServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/hydra/HydraServiceTest.kt index cae04866..b10a53de 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/hydra/HydraServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/hydra/HydraServiceTest.kt @@ -2,8 +2,8 @@ package com.faforever.userservice.backend.hydra import com.faforever.userservice.backend.domain.IpAddress import com.faforever.userservice.backend.domain.UserRepository -import com.faforever.userservice.backend.login.LoginResult -import com.faforever.userservice.backend.login.LoginService +import com.faforever.userservice.backend.security.LoginResult +import com.faforever.userservice.backend.security.LoginService import com.faforever.userservice.backend.security.OAuthScope import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest