Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User Control Panel #152

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -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 {
Expand Down
Expand Up @@ -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
Expand Down
Expand Up @@ -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(
Expand Down
Expand Up @@ -138,7 +138,7 @@ class RegistrationService(

val user = User(
username = username,
password = encodedPassword,
passwordHash = encodedPassword,
email = email,
ip = ipAddress.value,
)
Expand Down
@@ -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
}
}
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -119,20 +102,20 @@ 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
val totalFailedAttempts = failedAttemptsSummary.totalAttempts

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")
Expand Down
@@ -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
}
}
19 changes: 19 additions & 0 deletions src/main/kotlin/com/faforever/userservice/config/FafProperties.kt
Expand Up @@ -22,6 +22,8 @@ interface FafProperties {
@NotBlank
fun hydraBaseUrl(): String

fun security(): Security

fun account(): Account

fun jwt(): Jwt
Expand Down Expand Up @@ -52,6 +54,23 @@ interface FafProperties {
fun tokenTtl(): Long
}

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
}
Expand Down
80 changes: 80 additions & 0 deletions 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<out Component>,
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
}
}
}
Expand Up @@ -3,7 +3,7 @@ package com.faforever.userservice.ui.view.oauth2
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.backend.security.VaadinIpService
import com.faforever.userservice.config.FafProperties
import com.faforever.userservice.ui.component.FontAwesomeIcon
Expand Down
Expand Up @@ -118,10 +118,12 @@ class RegisterView(private val registrationService: RegistrationService, fafProp
getTranslation("register.username.taken"),
).bind("username")

binder.forField(email).withValidator(EmailValidator(getTranslation("register.email.invalid"))).withValidator(
{ email -> registrationService.emailAvailable(email) == EmailStatus.EMAIL_AVAILABLE },
getTranslation("register.email.taken"),
).bind("email")
binder.forField(email)
.withValidator(EmailValidator(getTranslation("register.email.invalid")))
.withValidator(
{ email -> registrationService.emailAvailable(email) == EmailStatus.EMAIL_AVAILABLE },
getTranslation("register.email.taken"),
).bind("email")

binder.forField(termsOfService).asRequired(getTranslation("register.acknowledge.terms")).bind("termsOfService")

Expand Down