From d599d2ab31a327d86784185d97d08c87b1a29b16 Mon Sep 17 00:00:00 2001 From: Sheikah45 <66929319+Sheikah45@users.noreply.github.com> Date: Thu, 30 Nov 2023 04:46:36 -0500 Subject: [PATCH] Add account registration and activation (#147) Co-authored-by: Brutus5000 --- build.gradle.kts | 2 + frontend/themes/faforever/styles.css | 5 + .../com/faforever/userservice/AppConfig.kt | 2 + .../backend/domain/DomainBlacklist.kt | 17 ++ .../userservice/backend/domain/NameRecord.kt | 44 +++++ .../userservice/backend/domain/User.kt | 10 +- .../userservice/backend/email/EmailService.kt | 69 ++++++++ .../backend/email/MailBodyBuilder.kt | 128 ++++++++++++++ .../userservice/backend/email/MailSender.kt | 16 ++ .../userservice/backend/hydra/HydraService.kt | 4 +- .../backend/{domain => login}/LoginService.kt | 19 +- .../backend/metrics/MetricHelper.kt | 89 ++++++++++ .../backend/recaptcha/RecaptchaService.kt | 60 +++++++ .../InvalidRegistrationException.kt | 3 + .../registration/RegistrationService.kt | 165 ++++++++++++++++++ .../backend/security/FafTokenService.kt | 88 ++++++++++ .../backend/security/VaadinIpService.kt | 16 ++ .../userservice/config/FafProperties.kt | 96 +++++++++- ...AuthLayout.kt => BackgroundImageLayout.kt} | 10 +- .../userservice/ui/layout/CompactLayout.kt | 5 +- .../ui/view/exception/GoneExceptionView.kt | 4 +- .../InvalidRegistrationExceptionView.kt | 52 ++++++ .../ui/view/exception/NoChallengeView.kt | 4 +- .../ui/view/{ => oauth2}/ConsentView.kt | 28 +-- .../ui/view/{ => oauth2}/LoginView.kt | 110 ++++++------ .../ui/view/registration/ActivateView.kt | 125 +++++++++++++ .../ui/view/registration/RegisterView.kt | 150 ++++++++++++++++ src/main/mjml/_footer.mjml | 36 ++++ src/main/mjml/_header.mjml | 9 + src/main/mjml/_style.mjml | 24 +++ src/main/mjml/activate-account.mjml | 56 ++++++ .../mjml/dummy/test-account-activation.html | 5 + src/main/mjml/dummy/test-password-reset.html | 4 + src/main/mjml/dummy/test-welcome-to-faf.html | 3 + src/main/mjml/password-reset.mjml | 50 ++++++ src/main/mjml/readme.md | 10 ++ src/main/mjml/welcome-to-faf.mjml | 104 +++++++++++ src/main/resources/application.yaml | 58 +++++- src/main/resources/i18n/messages.properties | 31 ++++ .../backend/domain/LoginServiceTest.kt | 3 + .../backend/hydra/HydraServiceTest.kt | 4 +- .../registration/RegistrationServiceTest.kt | 143 +++++++++++++++ .../backend/security/FAFTokenServiceTest.kt | 57 ++++++ src/test/resources/application.yaml | 7 + .../resources/mail/account-activation.html | 3 + src/test/resources/mail/password-reset.html | 3 + src/test/resources/mail/welcome-to-faf.html | 1 + 47 files changed, 1826 insertions(+), 106 deletions(-) create mode 100644 src/main/kotlin/com/faforever/userservice/backend/domain/DomainBlacklist.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/domain/NameRecord.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/email/EmailService.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/email/MailBodyBuilder.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/email/MailSender.kt rename src/main/kotlin/com/faforever/userservice/backend/{domain => login}/LoginService.kt (86%) create mode 100644 src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/recaptcha/RecaptchaService.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/security/FafTokenService.kt create mode 100644 src/main/kotlin/com/faforever/userservice/backend/security/VaadinIpService.kt rename src/main/kotlin/com/faforever/userservice/ui/layout/{OAuthLayout.kt => BackgroundImageLayout.kt} (85%) create mode 100644 src/main/kotlin/com/faforever/userservice/ui/view/exception/InvalidRegistrationExceptionView.kt rename src/main/kotlin/com/faforever/userservice/ui/view/{ => oauth2}/ConsentView.kt (81%) rename src/main/kotlin/com/faforever/userservice/ui/view/{ => oauth2}/LoginView.kt (70%) create mode 100644 src/main/kotlin/com/faforever/userservice/ui/view/registration/ActivateView.kt create mode 100644 src/main/kotlin/com/faforever/userservice/ui/view/registration/RegisterView.kt create mode 100644 src/main/mjml/_footer.mjml create mode 100644 src/main/mjml/_header.mjml create mode 100644 src/main/mjml/_style.mjml create mode 100644 src/main/mjml/activate-account.mjml create mode 100644 src/main/mjml/dummy/test-account-activation.html create mode 100644 src/main/mjml/dummy/test-password-reset.html create mode 100644 src/main/mjml/dummy/test-welcome-to-faf.html create mode 100644 src/main/mjml/password-reset.mjml create mode 100644 src/main/mjml/readme.md create mode 100644 src/main/mjml/welcome-to-faf.mjml create mode 100644 src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt create mode 100644 src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt create mode 100644 src/test/resources/mail/account-activation.html create mode 100644 src/test/resources/mail/password-reset.html create mode 100644 src/test/resources/mail/welcome-to-faf.html diff --git a/build.gradle.kts b/build.gradle.kts index 8f953c14..151a28f2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,8 @@ dependencies { implementation("io.quarkus:quarkus-config-yaml") implementation("io.quarkus:quarkus-smallrye-jwt") implementation("io.quarkus:quarkus-smallrye-jwt-build") + implementation("io.quarkus:quarkus-micrometer-registry-prometheus") + implementation("io.quarkus:quarkus-mailer") implementation("io.quarkus:quarkus-hibernate-validator") implementation("io.quarkus:quarkus-resteasy-reactive-jackson") implementation("io.quarkus:quarkus-hibernate-orm-panache-kotlin") diff --git a/frontend/themes/faforever/styles.css b/frontend/themes/faforever/styles.css index 2b218539..38828649 100644 --- a/frontend/themes/faforever/styles.css +++ b/frontend/themes/faforever/styles.css @@ -5,6 +5,7 @@ html { --lumo-primary-color: #ea2f10; --lumo-primary-text-color: var(--lumo-primary-color); --lumo-clickable-cursor: pointer; + --lumo-contrast-20pct: #b8b8be } .background { @@ -170,3 +171,7 @@ a:hover { .tooltip:hover .tooltiptext { visibility: visible; } + +.policy-link { + padding-left: 5px; +} \ No newline at end of file diff --git a/src/main/kotlin/com/faforever/userservice/AppConfig.kt b/src/main/kotlin/com/faforever/userservice/AppConfig.kt index e57f4622..4827a5ac 100644 --- a/src/main/kotlin/com/faforever/userservice/AppConfig.kt +++ b/src/main/kotlin/com/faforever/userservice/AppConfig.kt @@ -2,6 +2,8 @@ package com.faforever.userservice import com.vaadin.flow.component.page.AppShellConfigurator import com.vaadin.flow.theme.Theme +import jakarta.enterprise.context.ApplicationScoped @Theme("faforever") +@ApplicationScoped class AppConfig : AppShellConfigurator diff --git a/src/main/kotlin/com/faforever/userservice/backend/domain/DomainBlacklist.kt b/src/main/kotlin/com/faforever/userservice/backend/domain/DomainBlacklist.kt new file mode 100644 index 00000000..c17d2c35 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/domain/DomainBlacklist.kt @@ -0,0 +1,17 @@ +package com.faforever.userservice.backend.domain + +import io.quarkus.hibernate.orm.panache.kotlin.PanacheRepositoryBase +import jakarta.enterprise.context.ApplicationScoped +import jakarta.persistence.Entity +import jakarta.persistence.Id + +@Entity(name = "email_domain_blacklist") +data class DomainBlacklist( + @Id + val domain: String, +) + +@ApplicationScoped +class DomainBlacklistRepository : PanacheRepositoryBase { + fun existsByDomain(domain: String): Boolean = count("domain = ?1", domain) > 0 +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/domain/NameRecord.kt b/src/main/kotlin/com/faforever/userservice/backend/domain/NameRecord.kt new file mode 100644 index 00000000..9c7aeb60 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/domain/NameRecord.kt @@ -0,0 +1,44 @@ +package com.faforever.userservice.backend.domain + +import io.quarkus.hibernate.orm.panache.kotlin.PanacheRepositoryBase +import jakarta.enterprise.context.ApplicationScoped +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import java.time.OffsetDateTime + +@Entity(name = "name_history") +data class NameRecord( + @Id + val id: Int, + @Column(name = "user_id") + val userId: Int, + @Column(name = "change_time") + val changeTime: OffsetDateTime, + @Column(name = "previous_name") + val previousName: String, +) + +@ApplicationScoped +class NameRecordRepository : PanacheRepositoryBase { + fun existsByUserIdAndChangeTimeAfter( + userId: Int, + changeTime: OffsetDateTime, + ): Boolean = count("userId = ?1 and changeTime >= ?2", userId, changeTime) > 0 + + fun existsByPreviousNameAndChangeTimeAfter( + previousName: String, + changeTime: OffsetDateTime, + ): Boolean = count("previousName = ?1 and changeTime >= ?2", previousName, changeTime) > 0 + + fun existsByPreviousNameAndChangeTimeAfterAndUserIdNotEquals( + previousName: String, + changeTime: OffsetDateTime, + userId: Int, + ): Boolean = count( + "previousName = ?1 and changeTime >= ?2 and userId != ?3", + previousName, + changeTime, + userId, + ) > 0 +} 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 fc1574f2..747549a9 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt @@ -15,8 +15,8 @@ import java.time.LocalDateTime @Entity(name = "login") data class User( @Id - @GeneratedValue - val id: Int, + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Int? = null, @Column(name = "login") val username: String, val password: String, @@ -75,10 +75,14 @@ class UserRepository : PanacheRepositoryBase { Permission::class.java, ).setParameter("userId", userId) .resultList as List + + fun existsByUsername(username: String): Boolean = count("username = ?1", username) > 0 + + fun existsByEmail(email: String): Boolean = count("email = ?1", email) > 0 } @ApplicationScoped class AccountLinkRepository : PanacheRepositoryBase { fun hasOwnershipLink(userId: Int): Boolean = - find("userId = ?1 and ownership", userId).firstResult() != null + count("userId = ?1 and ownership", userId) > 0 } diff --git a/src/main/kotlin/com/faforever/userservice/backend/email/EmailService.kt b/src/main/kotlin/com/faforever/userservice/backend/email/EmailService.kt new file mode 100644 index 00000000..6f1f598a --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/email/EmailService.kt @@ -0,0 +1,69 @@ +package com.faforever.userservice.backend.email + +import com.faforever.userservice.backend.domain.DomainBlacklistRepository +import com.faforever.userservice.backend.domain.User +import com.faforever.userservice.backend.domain.UserRepository +import com.faforever.userservice.config.FafProperties +import jakarta.enterprise.context.ApplicationScoped +import jakarta.transaction.Transactional +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.util.regex.Pattern + +@ApplicationScoped +class EmailService( + private val userRepository: UserRepository, + private val domainBlacklistRepository: DomainBlacklistRepository, + private val properties: FafProperties, + private val mailSender: MailSender, + private val mailBodyBuilder: MailBodyBuilder, +) { + + companion object { + private val log: Logger = LoggerFactory.getLogger(EmailService::class.java) + private val EMAIL_PATTERN: Pattern = Pattern.compile(".+@.+\\..+$") + } + + enum class ValidationResult { + VALID, + INVALID, + BLACKLISTED, + } + + fun changeUserEmail(newEmail: String, user: User) { + validateEmailAddress(newEmail) + log.debug("Changing email for user '${user.username}' to '$newEmail'") + val updatedUser = user.copy(email = newEmail) + userRepository.persist(updatedUser) + // TODO: broadcastUserChange(user) + } + + /** + * Checks whether the specified email address as a valid format and its domain is not blacklisted. + */ + @Transactional + fun validateEmailAddress(email: String) = when { + !EMAIL_PATTERN.matcher(email).matches() -> ValidationResult.INVALID + + domainBlacklistRepository.existsByDomain( + email.substring(email.lastIndexOf('@') + 1), + ) -> ValidationResult.BLACKLISTED + + else -> ValidationResult.VALID + } + + fun sendActivationMail(username: String, email: String, activationUrl: String) { + val mailBody = mailBodyBuilder.buildAccountActivationBody(username, activationUrl) + mailSender.sendMail(email, properties.account().registration().subject(), mailBody) + } + + fun sendWelcomeToFafMail(username: String, email: String) { + val mailBody = mailBodyBuilder.buildWelcomeToFafBody(username) + mailSender.sendMail(email, properties.account().registration().welcomeSubject(), mailBody) + } + + fun sendPasswordResetMail(username: String, email: String, passwordResetUrl: String) { + val mailBody = mailBodyBuilder.buildPasswordResetBody(username, passwordResetUrl) + mailSender.sendMail(email, properties.account().passwordReset().subject(), mailBody) + } +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/email/MailBodyBuilder.kt b/src/main/kotlin/com/faforever/userservice/backend/email/MailBodyBuilder.kt new file mode 100644 index 00000000..ab57f173 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/email/MailBodyBuilder.kt @@ -0,0 +1,128 @@ +package com.faforever.userservice.backend.email + +import com.faforever.userservice.config.FafProperties +import io.quarkus.runtime.StartupEvent +import jakarta.ejb.Startup +import jakarta.enterprise.event.Observes +import jakarta.inject.Singleton +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.file.Files +import java.nio.file.Path + +@Startup +@Singleton +class MailBodyBuilder(private val properties: FafProperties) { + + companion object { + private val log: Logger = LoggerFactory.getLogger(MailBodyBuilder::class.java) + } + + enum class Template(vararg variables: String) { + ACCOUNT_ACTIVATION("username", "activationUrl"), + WELCOME_TO_FAF("username"), + PASSWORD_RESET("username", "passwordResetUrl"), + ; + + val variables: Set + + init { + this.variables = setOf(*variables) + } + } + + private fun getTemplateFilePath(template: Template): Path { + val path = when (template) { + Template.ACCOUNT_ACTIVATION -> properties.account().registration().activationMailTemplatePath() + Template.WELCOME_TO_FAF -> properties.account().registration().welcomeMailTemplatePath() + Template.PASSWORD_RESET -> properties.account().passwordReset().mailTemplatePath() + } + return Path.of(path) + } + + fun onStart(@Observes event: StartupEvent) { + var templateError = false + for (template in Template.values()) { + val path = getTemplateFilePath(template) + if (Files.exists(path)) { + log.debug("Template {} has template file present at {}", template, path) + } else { + templateError = true + log.error("Template {} is missing file at configured destination: {}", template, path) + } + try { + loadAndValidateTemplate(template) + } catch (e: Exception) { + log.error("Template {} has invalid template file at {}. Error: {}", template, path, e.message) + templateError = true + } + } + check(!templateError) { "At least one template file is not available or inconsistent." } + log.info("All template files present.") + } + + private fun loadAndValidateTemplate(template: Template): String { + val templateBody = Files.readString(getTemplateFilePath(template)) + val missingVariables = template.variables + .map { "{{$it}}" } + .filterNot { templateBody.contains(it) } + .joinToString(separator = ", ") + check(missingVariables.isEmpty()) { + "Template file for $template is missing variables: $missingVariables" + } + + return templateBody + } + + private fun validateVariables(template: Template, variables: Set) { + val missingVariables = template.variables + .filterNot { variables.contains(it) } + .joinToString(separator = ", ") + val unknownVariables = variables + .filterNot { template.variables.contains(it) } + .joinToString(separator = ", ") + if (unknownVariables.isNotEmpty()) { + log.warn("Unknown variable(s) handed over for template {}: {}", template, unknownVariables) + } + require(missingVariables.isEmpty()) { "Variable(s) not assigned: $missingVariables" } + } + + private fun populate(template: Template, variables: Map): String { + validateVariables(template, variables.keys) + var templateBody = loadAndValidateTemplate(template) + log.trace("Raw template body: {}", templateBody) + for ((key, value) in variables) { + val variable = "{{$key}}" + log.trace("Replacing {} with {}", variable, value) + templateBody = templateBody.replace(variable, value) + } + log.trace("Replaced template body: {}", templateBody) + return templateBody + } + + fun buildAccountActivationBody(username: String, activationUrl: String) = + populate( + Template.ACCOUNT_ACTIVATION, + mapOf( + "username" to username, + "activationUrl" to activationUrl, + ), + ) + + fun buildWelcomeToFafBody(username: String) = + populate( + Template.WELCOME_TO_FAF, + mapOf( + "username" to username, + ), + ) + + fun buildPasswordResetBody(username: String, passwordResetUrl: String) = + populate( + Template.PASSWORD_RESET, + mapOf( + "username" to username, + "passwordResetUrl" to passwordResetUrl, + ), + ) +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/email/MailSender.kt b/src/main/kotlin/com/faforever/userservice/backend/email/MailSender.kt new file mode 100644 index 00000000..f2a2930a --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/email/MailSender.kt @@ -0,0 +1,16 @@ +package com.faforever.userservice.backend.email + +import io.quarkus.mailer.Mail +import io.quarkus.mailer.Mailer +import jakarta.enterprise.context.ApplicationScoped + +@ApplicationScoped +class MailSender( + private val mailer: Mailer, +) { + fun sendMail(toEmail: String, subject: String, content: String) { + mailer.send( + Mail.withText(toEmail, subject, content), + ) + } +} 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 211f373b..351163d5 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt @@ -1,9 +1,9 @@ package com.faforever.userservice.backend.hydra import com.faforever.userservice.backend.domain.IpAddress -import com.faforever.userservice.backend.domain.LoginResult -import com.faforever.userservice.backend.domain.LoginService 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.OAuthScope import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.inject.Produces diff --git a/src/main/kotlin/com/faforever/userservice/backend/domain/LoginService.kt b/src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt similarity index 86% rename from src/main/kotlin/com/faforever/userservice/backend/domain/LoginService.kt rename to src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt index e1212716..00c8c56a 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/domain/LoginService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt @@ -1,5 +1,14 @@ -package com.faforever.userservice.backend.domain - +package com.faforever.userservice.backend.login + +import com.faforever.userservice.backend.domain.AccountLinkRepository +import com.faforever.userservice.backend.domain.Ban +import com.faforever.userservice.backend.domain.BanRepository +import com.faforever.userservice.backend.domain.FailedAttemptsSummary +import com.faforever.userservice.backend.domain.IpAddress +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 jakarta.enterprise.context.ApplicationScoped @@ -85,7 +94,7 @@ class LoginServiceImpl( return LoginResult.UserBanned(activeGlobalBan.reason, activeGlobalBan.expiresAt) } - if (requiresGameOwnership && !accountLinkRepository.hasOwnershipLink(user.id)) { + if (requiresGameOwnership && !accountLinkRepository.hasOwnershipLink(user.id!!)) { LOG.debug( "Lobby login blocked for user '{}' because of missing game ownership verification", usernameOrEmail, @@ -94,7 +103,7 @@ class LoginServiceImpl( } LOG.debug("User '{}' logged in successfully", usernameOrEmail) - return LoginResult.SuccessfulLogin(user.id, user.username) + return LoginResult.SuccessfulLogin(user.id!!, user.username) } private fun logLogin(user: User, ip: IpAddress) = @@ -104,7 +113,7 @@ class LoginServiceImpl( loginLogRepository.persist(LoginLog(0, null, unknownLogin.take(100), ip.value, false)) private fun findActiveGlobalBan(user: User): Ban? = - banRepository.findGlobalBansByPlayerId(user.id) + banRepository.findGlobalBansByPlayerId(user.id!!) .firstOrNull { it.isActive } private fun throttlingRequired(ip: IpAddress): Boolean { diff --git a/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt b/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt new file mode 100644 index 00000000..cfb036c9 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt @@ -0,0 +1,89 @@ +package com.faforever.userservice.backend.metrics + +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import jakarta.enterprise.context.ApplicationScoped + +@ApplicationScoped +class MetricHelper(meterRegistry: MeterRegistry) { + companion object { + private const val USER_REGISTRATIONS_COUNT = "user.registrations.count" + private const val USER_PASSWORD_RESET_COUNT = "user.password.reset.count" + private const val STEP_TAG = "step" + private const val MODE_TAG = "step" + } + + // User Registration Counters + val userRegistrationCounter: Counter = meterRegistry.counter( + USER_REGISTRATIONS_COUNT, + STEP_TAG, + "registration", + ) + val userActivationCounter: Counter = meterRegistry.counter( + USER_REGISTRATIONS_COUNT, + STEP_TAG, + "activation", + ) + val userSteamLinkRequestedCounter: Counter = meterRegistry.counter( + USER_REGISTRATIONS_COUNT, + STEP_TAG, + "steamLinkRequested", + ) + val userSteamLinkDoneCounter: Counter = meterRegistry.counter( + USER_REGISTRATIONS_COUNT, + STEP_TAG, + "steamLinkDone", + ) + val userSteamLinkFailedCounter: Counter = meterRegistry.counter( + USER_REGISTRATIONS_COUNT, + STEP_TAG, + "steamLinkFailed", + ) + + // Username Change Counters + val userNameChangeCounter: Counter = meterRegistry.counter("user.name.change.count") + + // Password Reset Counters + val userPasswordResetRequestCounter: Counter = meterRegistry.counter( + USER_PASSWORD_RESET_COUNT, + STEP_TAG, + "request", + MODE_TAG, + "email", + ) + val userPasswordResetViaSteamRequestCounter: Counter = meterRegistry.counter( + USER_PASSWORD_RESET_COUNT, + STEP_TAG, + "request", + MODE_TAG, + "steam", + ) + val userPasswordResetDoneCounter: Counter = meterRegistry.counter( + USER_PASSWORD_RESET_COUNT, + STEP_TAG, + "done", + MODE_TAG, + "email", + ) + val userPasswordResetFailedCounter: Counter = meterRegistry.counter( + USER_PASSWORD_RESET_COUNT, + STEP_TAG, + "failed", + MODE_TAG, + "email", + ) + val userPasswordResetDoneViaSteamCounter: Counter = meterRegistry.counter( + USER_PASSWORD_RESET_COUNT, + STEP_TAG, + "done", + MODE_TAG, + "steam", + ) + val userPasswordResetFailedViaSteamCounter: Counter = meterRegistry.counter( + USER_PASSWORD_RESET_COUNT, + STEP_TAG, + "failed", + MODE_TAG, + "steam", + ) +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/recaptcha/RecaptchaService.kt b/src/main/kotlin/com/faforever/userservice/backend/recaptcha/RecaptchaService.kt new file mode 100644 index 00000000..1142bb31 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/recaptcha/RecaptchaService.kt @@ -0,0 +1,60 @@ +package com.faforever.userservice.backend.recaptcha + +import com.faforever.userservice.config.FafProperties +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.enterprise.context.ApplicationScoped +import jakarta.ws.rs.POST +import jakarta.ws.rs.Path +import jakarta.ws.rs.QueryParam +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient +import org.eclipse.microprofile.rest.client.inject.RestClient +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.time.OffsetDateTime + +@ApplicationScoped +@RegisterRestClient(configKey = "recaptcha") +interface RecaptchaClient { + + @POST + @Path("/siteverify") + fun validateResponse( + @QueryParam("secret") secret: String, + @QueryParam("response") response: String?, + ): VerifyResponse +} + +@ApplicationScoped +class RecaptchaService( + private val fafProperties: FafProperties, + @RestClient private val recaptchaClient: RecaptchaClient, +) { + companion object { + val LOG: Logger = LoggerFactory.getLogger(RecaptchaService::class.java) + } + + fun validateResponse(recaptchaResponse: String?) { + if (!fafProperties.recaptcha().enabled()) { + LOG.debug("Recaptcha validation is disabled") + return + } + + LOG.debug("Validating response: {}", recaptchaResponse) + + val validateResponse = recaptchaClient.validateResponse(fafProperties.recaptcha().secret(), recaptchaResponse) + + if (!validateResponse.success) { + LOG.debug("Recaptcha validation failed for reasons: {}", validateResponse.errorCodes) + throw IllegalStateException("Recaptcha validation failed") + } + + LOG.debug("Recaptcha validation successful") + } +} + +data class VerifyResponse( + val success: Boolean, + val challengeTs: OffsetDateTime?, + val hostname: String, + @JsonProperty("error-codes") val errorCodes: List?, +) diff --git a/src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt b/src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt new file mode 100644 index 00000000..0ef4ad2f --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt @@ -0,0 +1,3 @@ +package com.faforever.userservice.backend.registration + +class InvalidRegistrationException : RuntimeException() diff --git a/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt b/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt new file mode 100644 index 00000000..6c02f242 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt @@ -0,0 +1,165 @@ +package com.faforever.userservice.backend.registration + +import com.faforever.userservice.backend.domain.DomainBlacklistRepository +import com.faforever.userservice.backend.domain.IpAddress +import com.faforever.userservice.backend.domain.NameRecordRepository +import com.faforever.userservice.backend.domain.User +import com.faforever.userservice.backend.domain.UserRepository +import com.faforever.userservice.backend.email.EmailService +import com.faforever.userservice.backend.metrics.MetricHelper +import com.faforever.userservice.backend.security.FafTokenService +import com.faforever.userservice.backend.security.FafTokenType +import com.faforever.userservice.backend.security.PasswordEncoder +import com.faforever.userservice.config.FafProperties +import jakarta.enterprise.context.ApplicationScoped +import jakarta.transaction.Transactional +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.time.Duration +import java.time.OffsetDateTime + +enum class UsernameStatus { + USERNAME_TAKEN, USERNAME_RESERVED, USERNAME_AVAILABLE, +} + +enum class EmailStatus { + EMAIL_TAKEN, EMAIL_BLACKLISTED, EMAIL_AVAILABLE, +} + +data class RegisteredUser( + val username: String, + val email: String, +) + +@ApplicationScoped +class RegistrationService( + private val userRepository: UserRepository, + private val nameRecordRepository: NameRecordRepository, + private val domainBlacklistRepository: DomainBlacklistRepository, + private val passwordEncoder: PasswordEncoder, + private val fafTokenService: FafTokenService, + private val fafProperties: FafProperties, + private val emailService: EmailService, + private val metricHelper: MetricHelper, +) { + companion object { + private val LOG: Logger = LoggerFactory.getLogger(RegistrationService::class.java) + private const val KEY_USERNAME = "username" + private const val KEY_EMAIL = "email" + private const val KEY_USER_ID = "id" + } + + fun register(username: String, email: String) { + checkUsernameAndEmail(username, email) + + sendActivationEmail(username, email) + metricHelper.userRegistrationCounter.increment() + } + + private fun sendActivationEmail(username: String, email: String) { + val token = fafTokenService.createToken( + FafTokenType.REGISTRATION, + Duration.ofSeconds(fafProperties.account().registration().linkExpirationSeconds()), + mapOf( + KEY_USERNAME to username, + KEY_EMAIL to email, + ), + ) + val activationUrl = fafProperties.account().registration().activationUrlFormat().format(token) + emailService.sendActivationMail(username, email, activationUrl) + } + + fun resetPassword(user: User) { + sendPasswordResetEmail(user) + metricHelper.userPasswordResetRequestCounter.increment() + } + + private fun sendPasswordResetEmail(user: User) { + val token = fafTokenService.createToken( + FafTokenType.REGISTRATION, + Duration.ofSeconds(fafProperties.account().passwordReset().linkExpirationSeconds()), + mapOf( + KEY_USER_ID to user.id.toString(), + ), + ) + val passwordResetUrl = fafProperties.account().passwordReset().passwordResetUrlFormat().format(token) + emailService.sendPasswordResetMail(user.username, user.email, passwordResetUrl) + } + + @Transactional + fun usernameAvailable(username: String): UsernameStatus { + val exists = userRepository.existsByUsername(username) + if (exists) { + return UsernameStatus.USERNAME_TAKEN + } + + val reserved = nameRecordRepository.existsByPreviousNameAndChangeTimeAfter( + username, + OffsetDateTime.now().minusMonths(fafProperties.account().username().usernameReservationTimeInMonths()), + ) + + return if (reserved) UsernameStatus.USERNAME_RESERVED else UsernameStatus.USERNAME_AVAILABLE + } + + @Transactional + fun emailAvailable(email: String): EmailStatus { + val onBlacklist = domainBlacklistRepository.existsByDomain(email.substring(email.lastIndexOf('@') + 1)) + if (onBlacklist) { + return EmailStatus.EMAIL_BLACKLISTED + } + + val exists = userRepository.existsByEmail(email) + return if (exists) EmailStatus.EMAIL_TAKEN else EmailStatus.EMAIL_AVAILABLE + } + + fun validateRegistrationToken(registrationToken: String): RegisteredUser { + val claims: Map + try { + claims = fafTokenService.getTokenClaims(FafTokenType.REGISTRATION, registrationToken) + } catch (exception: Exception) { + LOG.error("Unable to extract claims", exception) + throw InvalidRegistrationException() + } + + if (claims[KEY_USERNAME].isNullOrBlank() || claims[KEY_EMAIL].isNullOrBlank()) { + throw InvalidRegistrationException() + } + + return RegisteredUser(claims[KEY_USERNAME]!!, claims[KEY_EMAIL]!!) + } + + @Transactional + fun activate(registeredUser: RegisteredUser, ipAddress: IpAddress, password: String): User { + val username = registeredUser.username + val email = registeredUser.email + val encodedPassword = passwordEncoder.encode(password) + + checkUsernameAndEmail(username, email) + + val user = User( + username = username, + password = encodedPassword, + email = email, + ip = ipAddress.value, + ) + + userRepository.persist(user) + + LOG.info("User has been activated: {}", user) + metricHelper.userActivationCounter.increment() + + return user + } + + private fun checkUsernameAndEmail(username: String, email: String) { + val usernameStatus = usernameAvailable(username) + if (usernameStatus != UsernameStatus.USERNAME_AVAILABLE) { + throw IllegalArgumentException("Username unavailable") + } + + val emailStatus = emailAvailable(email) + if (emailStatus != EmailStatus.EMAIL_AVAILABLE) { + throw IllegalArgumentException("Email unavailable") + } + } +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/security/FafTokenService.kt b/src/main/kotlin/com/faforever/userservice/backend/security/FafTokenService.kt new file mode 100644 index 00000000..823ccaf4 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/security/FafTokenService.kt @@ -0,0 +1,88 @@ +package com.faforever.userservice.backend.security + +import com.faforever.userservice.config.FafProperties +import com.nimbusds.jose.EncryptionMethod +import com.nimbusds.jose.JWEAlgorithm +import com.nimbusds.jose.JWEHeader +import com.nimbusds.jose.crypto.AESDecrypter +import com.nimbusds.jose.crypto.AESEncrypter +import com.nimbusds.jwt.EncryptedJWT +import com.nimbusds.jwt.JWTClaimsSet +import jakarta.enterprise.context.ApplicationScoped +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.security.spec.KeySpec +import java.text.MessageFormat +import java.time.Instant +import java.time.temporal.TemporalAmount +import java.util.* +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import javax.crypto.spec.SecretKeySpec + +enum class FafTokenType { + REGISTRATION, + PASSWORD_RESET, + LINK_TO_STEAM, +} + +class SecretKeyGenerator { + companion object { + fun getKeyFromString(salt: String): SecretKey { + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec: KeySpec = PBEKeySpec(null, salt.toByteArray(), 262144, 256) + return SecretKeySpec(factory.generateSecret(spec).encoded, "AES") + } + } +} + +@ApplicationScoped +class FafTokenService( + fafProperties: FafProperties, +) { + companion object { + private val LOG: Logger = LoggerFactory.getLogger(FafTokenService::class.java) + private const val KEY_ACTION = "action" + } + + private val secretKey = SecretKeyGenerator.getKeyFromString(fafProperties.jwt().secret()) + private val jweEncrypter = AESEncrypter(secretKey) + private val jweDecrypter = AESDecrypter(secretKey) + + fun createToken(fafTokenType: FafTokenType, lifetime: TemporalAmount, attributes: Map): String { + if (attributes.containsKey(KEY_ACTION)) { + throw IllegalArgumentException( + MessageFormat.format("'{0}' is a protected attributed and must not be used", KEY_ACTION), + ) + } + + val jwtBuilder = JWTClaimsSet.Builder() + .expirationTime(Date.from(Instant.now().plus(lifetime))) + .issueTime(Date.from(Instant.now())) + + jwtBuilder.claim(KEY_ACTION, fafTokenType.name) + attributes.forEach { (key, value) -> jwtBuilder.claim(key, value) } + + val jwe = EncryptedJWT(JWEHeader(JWEAlgorithm.A256KW, EncryptionMethod.A128CBC_HS256), jwtBuilder.build()) + jwe.encrypt(jweEncrypter) + + return jwe.serialize() + } + + fun getTokenClaims(fafTokenType: FafTokenType, tokenValue: String): Map { + LOG.debug("Reading token of expected type {}", fafTokenType.name) + + val jwe = EncryptedJWT.parse(tokenValue) + jwe.decrypt(jweDecrypter) + + val tokenClaims = jwe.jwtClaimsSet + if (tokenClaims.expirationTime?.before(Date.from(Instant.now())) == true) { + throw IllegalArgumentException("Token is expired") + } + if (tokenClaims?.claims?.get(KEY_ACTION) != fafTokenType.name) { + throw IllegalArgumentException("Token does not match expected type") + } + return tokenClaims.claims.filterKeys { it != KEY_ACTION } as Map + } +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/security/VaadinIpService.kt b/src/main/kotlin/com/faforever/userservice/backend/security/VaadinIpService.kt new file mode 100644 index 00000000..6f385d95 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/security/VaadinIpService.kt @@ -0,0 +1,16 @@ +package com.faforever.userservice.backend.security + +import com.faforever.userservice.backend.domain.IpAddress +import com.faforever.userservice.config.FafProperties +import com.vaadin.flow.server.VaadinRequest +import jakarta.enterprise.context.ApplicationScoped + +@ApplicationScoped +class VaadinIpService(private val fafProperties: FafProperties) { + + fun getRealIp(): IpAddress { + val currentRequest = VaadinRequest.getCurrent() + val realIp = currentRequest.getHeader(fafProperties.realIpHeader()) ?: currentRequest.remoteAddr + return IpAddress(realIp) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt index c62e3083..6a7273cf 100644 --- a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt +++ b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt @@ -1,6 +1,7 @@ package com.faforever.userservice.config import io.smallrye.config.ConfigMapping +import io.smallrye.config.WithDefault import io.smallrye.config.WithName import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull @@ -21,17 +22,16 @@ interface FafProperties { @NotBlank fun hydraBaseUrl(): String - @NotBlank - fun passwordResetUrl(): String + fun account(): Account - @NotBlank - fun registerAccountUrl(): String + fun jwt(): Jwt - @NotBlank - fun accountLinkUrl(): String + fun recaptcha(): Recaptcha fun lobby(): Lobby + fun irc(): Irc + interface Lobby { @NotBlank fun secret(): String @@ -43,8 +43,6 @@ interface FafProperties { fun accessUri(): URI } - fun irc(): Irc - interface Irc { @WithName("fixed.users") fun fixedUsers(): Map @@ -53,4 +51,86 @@ interface FafProperties { fun tokenTtl(): Long } + + interface Jwt { + fun secret(): String + } + + interface Recaptcha { + fun enabled(): Boolean + + @NotBlank + fun secret(): String + + @NotBlank + fun siteKey(): String + } + + interface Account { + @NotBlank + fun passwordResetUrl(): String + + @NotBlank + fun registerAccountUrl(): String + + @NotBlank + fun accountLinkUrl(): String + + fun registration(): Registration + + fun passwordReset(): PasswordReset + + fun username(): Username + + interface Registration { + @WithDefault("3600") + fun linkExpirationSeconds(): Long + + @NotBlank + fun activationUrlFormat(): String + + @NotBlank + fun subject(): String + + @NotBlank + fun activationMailTemplatePath(): String + + @NotBlank + fun welcomeSubject(): String + + @NotBlank + fun welcomeMailTemplatePath(): String + + @NotBlank + fun termsOfServiceUrl(): String + + @NotBlank + fun privacyStatementUrl(): String + + @NotBlank + fun rulesUrl(): String + } + + interface PasswordReset { + @WithDefault("3600") + fun linkExpirationSeconds(): Long + + @NotBlank + fun passwordResetUrlFormat(): String + + @NotBlank + fun subject(): String + + @NotBlank + fun mailTemplatePath(): String + } + + interface Username { + @WithDefault("30") + fun minimumDaysBetweenUsernameChange(): Int + + @WithDefault("6") + fun usernameReservationTimeInMonths(): Long + } + } } diff --git a/src/main/kotlin/com/faforever/userservice/ui/layout/OAuthLayout.kt b/src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt similarity index 85% rename from src/main/kotlin/com/faforever/userservice/ui/layout/OAuthLayout.kt rename to src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt index 5f1e3c87..a94e857d 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/layout/OAuthLayout.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt @@ -13,7 +13,7 @@ import com.vaadin.flow.router.RouterLayout import jakarta.enterprise.context.Dependent @Dependent -class OAuthLayout(oAuthHeader: OAuthHeader) : CompactVerticalLayout(), RouterLayout { +class BackgroundImageLayout(backgroundHeader: BackgroundHeader) : CompactVerticalLayout(), RouterLayout { companion object { val BACKGROUND_IMAGES = arrayOf( @@ -29,12 +29,12 @@ class OAuthLayout(oAuthHeader: OAuthHeader) : CompactVerticalLayout(), RouterLay addClassName("background") style.set("background-image", "url(${BACKGROUND_IMAGES.random()})") - add(oAuthHeader) + add(backgroundHeader) } } @Dependent -class OAuthHeader(fafProperties: FafProperties) : Header() { +class BackgroundHeader(fafProperties: FafProperties) : Header() { init { setWidthFull() setHeight(50f, Unit.PIXELS) @@ -57,8 +57,8 @@ class OAuthHeader(fafProperties: FafProperties) : Header() { } } -@ParentLayout(OAuthLayout::class) -class OAuthCardLayout : VerticalLayout(), RouterLayout { +@ParentLayout(BackgroundImageLayout::class) +class CardLayout : VerticalLayout(), RouterLayout { init { addClassName("main-card") diff --git a/src/main/kotlin/com/faforever/userservice/ui/layout/CompactLayout.kt b/src/main/kotlin/com/faforever/userservice/ui/layout/CompactLayout.kt index 07640cac..e516cf5a 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/layout/CompactLayout.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/layout/CompactLayout.kt @@ -1,16 +1,17 @@ package com.faforever.userservice.ui.layout +import com.vaadin.flow.component.Component import com.vaadin.flow.component.orderedlayout.HorizontalLayout import com.vaadin.flow.component.orderedlayout.VerticalLayout -open class CompactVerticalLayout : VerticalLayout() { +open class CompactVerticalLayout(vararg children: Component) : VerticalLayout(*children) { init { isPadding = false isSpacing = false } } -open class CompactHorizontalLayout : HorizontalLayout() { +open class CompactHorizontalLayout(vararg children: Component) : HorizontalLayout(*children) { init { isPadding = false isSpacing = false diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/exception/GoneExceptionView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/exception/GoneExceptionView.kt index 2f4836fb..1410b5c7 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/exception/GoneExceptionView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/exception/GoneExceptionView.kt @@ -2,7 +2,7 @@ package com.faforever.userservice.ui.view.exception import com.faforever.userservice.backend.hydra.GoneException import com.faforever.userservice.ui.component.ErrorCard -import com.faforever.userservice.ui.layout.OAuthCardLayout +import com.faforever.userservice.ui.layout.CardLayout import com.vaadin.flow.router.BeforeEnterEvent import com.vaadin.flow.router.ErrorParameter import com.vaadin.flow.router.HasErrorParameter @@ -10,7 +10,7 @@ import com.vaadin.flow.router.ParentLayout import jakarta.servlet.http.HttpServletResponse @Suppress("unused") -@ParentLayout(OAuthCardLayout::class) +@ParentLayout(CardLayout::class) class GoneExceptionView : ErrorCard(), HasErrorParameter { init { diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/exception/InvalidRegistrationExceptionView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/exception/InvalidRegistrationExceptionView.kt new file mode 100644 index 00000000..6446a37d --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/exception/InvalidRegistrationExceptionView.kt @@ -0,0 +1,52 @@ +package com.faforever.userservice.ui.view.exception + +import com.faforever.userservice.backend.registration.InvalidRegistrationException +import com.faforever.userservice.ui.component.FafLogo +import com.faforever.userservice.ui.layout.CardLayout +import com.faforever.userservice.ui.layout.CompactVerticalLayout +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.html.Span +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.router.BeforeEnterEvent +import com.vaadin.flow.router.ErrorParameter +import com.vaadin.flow.router.HasErrorParameter +import com.vaadin.flow.router.ParentLayout +import jakarta.servlet.http.HttpServletResponse + +@Suppress("unused") +@ParentLayout(CardLayout::class) +class InvalidRegistrationExceptionView : CompactVerticalLayout(), HasErrorParameter { + + private val errorMessage = Span().apply { + text = getTranslation("register.technicalError") + } + private val errorLayout = HorizontalLayout(errorMessage).apply { + alignItems = FlexComponent.Alignment.CENTER + setWidthFull() + addClassNames("error", "error-info") + setVerticalComponentAlignment(FlexComponent.Alignment.CENTER) + } + + init { + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("title.technicalError")) + + val formHeader = HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + alignItems = FlexComponent.Alignment.CENTER + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + setWidthFull() + setId("form-header") + } + + add(formHeader) + add(errorLayout) + } + + override fun setErrorParameter( + event: BeforeEnterEvent?, + parameter: ErrorParameter?, + ): Int { + return HttpServletResponse.SC_INTERNAL_SERVER_ERROR + } +} diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/exception/NoChallengeView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/exception/NoChallengeView.kt index 6e258ece..e827b743 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/exception/NoChallengeView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/exception/NoChallengeView.kt @@ -2,7 +2,7 @@ package com.faforever.userservice.ui.view.exception import com.faforever.userservice.backend.hydra.NoChallengeException import com.faforever.userservice.ui.component.ErrorCard -import com.faforever.userservice.ui.layout.OAuthCardLayout +import com.faforever.userservice.ui.layout.CardLayout import com.vaadin.flow.router.BeforeEnterEvent import com.vaadin.flow.router.ErrorParameter import com.vaadin.flow.router.HasErrorParameter @@ -10,7 +10,7 @@ import com.vaadin.flow.router.ParentLayout import jakarta.servlet.http.HttpServletResponse @Suppress("unused") -@ParentLayout(OAuthCardLayout::class) +@ParentLayout(CardLayout::class) class NoChallengeView : ErrorCard(), HasErrorParameter { init { diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/ConsentView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/ConsentView.kt similarity index 81% rename from src/main/kotlin/com/faforever/userservice/ui/view/ConsentView.kt rename to src/main/kotlin/com/faforever/userservice/ui/view/oauth2/ConsentView.kt index bb12da9c..ac7b6887 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/ConsentView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/ConsentView.kt @@ -1,12 +1,12 @@ -package com.faforever.userservice.ui.view +package com.faforever.userservice.ui.view.oauth2 import com.faforever.userservice.backend.hydra.HydraService import com.faforever.userservice.backend.hydra.NoChallengeException import com.faforever.userservice.ui.component.OAuthClientHeader import com.faforever.userservice.ui.component.ScopeWidget import com.faforever.userservice.ui.component.SocialIcons +import com.faforever.userservice.ui.layout.CardLayout import com.faforever.userservice.ui.layout.CompactVerticalLayout -import com.faforever.userservice.ui.layout.OAuthCardLayout import com.vaadin.flow.component.button.Button import com.vaadin.flow.component.button.ButtonVariant import com.vaadin.flow.component.orderedlayout.FlexComponent @@ -16,13 +16,16 @@ import com.vaadin.flow.router.BeforeEnterObserver import com.vaadin.flow.router.Route import sh.ory.hydra.model.ConsentRequest -@Route("/oauth2/consent", layout = OAuthCardLayout::class) +@Route("/oauth2/consent", layout = CardLayout::class) class ConsentView( private val oAuthClientHeader: OAuthClientHeader, private val scopeWidget: ScopeWidget, private val hydraService: HydraService, ) : CompactVerticalLayout(), BeforeEnterObserver { - private val authorize = Button(getTranslation("consent.authorize")) { authorize() } + + private val authorize = Button(getTranslation("consent.authorize")) { authorize() }.apply { + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + } private val deny = Button(getTranslation("consent.deny")) { deny() } private lateinit var challenge: String @@ -31,16 +34,17 @@ class ConsentView( add(oAuthClientHeader) add(scopeWidget) - authorize.addThemeVariants(ButtonVariant.LUMO_PRIMARY) + val buttonLayout = HorizontalLayout(deny, authorize).apply { + alignItems = FlexComponent.Alignment.STRETCH + setFlexGrow(1.0, deny, authorize) + setWidthFull() + } - val buttonLayout = HorizontalLayout(deny, authorize) - buttonLayout.setFlexGrow(1.0, deny, authorize) - buttonLayout.setWidthFull() - buttonLayout.alignItems = FlexComponent.Alignment.STRETCH add(buttonLayout) - val socialIcons = SocialIcons() - socialIcons.setWidthFull() + val socialIcons = SocialIcons().apply { + setWidthFull() + } add(socialIcons) } @@ -68,7 +72,7 @@ class ConsentView( val possibleChallenge = event?.location?.queryParameters?.parameters?.get("consent_challenge")?.get(0) if (possibleChallenge != null) { challenge = possibleChallenge - setDetailsFromRequest(hydraService.getConsentRequest(possibleChallenge)) + setDetailsFromRequest(hydraService.getConsentRequest(challenge)) } else { throw NoChallengeException() } diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/LoginView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt similarity index 70% rename from src/main/kotlin/com/faforever/userservice/ui/view/LoginView.kt rename to src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt index 29b0921d..17dadc67 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/LoginView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt @@ -1,16 +1,16 @@ -package com.faforever.userservice.ui.view +package com.faforever.userservice.ui.view.oauth2 -import com.faforever.userservice.backend.domain.IpAddress -import com.faforever.userservice.backend.domain.LoginResult 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.VaadinIpService import com.faforever.userservice.config.FafProperties import com.faforever.userservice.ui.component.FontAwesomeIcon import com.faforever.userservice.ui.component.LogoHeader import com.faforever.userservice.ui.component.SocialIcons +import com.faforever.userservice.ui.layout.CardLayout import com.faforever.userservice.ui.layout.CompactVerticalLayout -import com.faforever.userservice.ui.layout.OAuthCardLayout import com.vaadin.flow.component.Key import com.vaadin.flow.component.button.Button import com.vaadin.flow.component.button.ButtonVariant @@ -24,79 +24,70 @@ 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 com.vaadin.flow.server.VaadinRequest import java.time.format.DateTimeFormatter -@Route("/oauth2/login", layout = OAuthCardLayout::class) -class LoginView(private val hydraService: HydraService, private val fafProperties: FafProperties) : +@Route("/oauth2/login", layout = CardLayout::class) +class LoginView(private val hydraService: HydraService, private val vaadinIpService: VaadinIpService, private val fafProperties: FafProperties) : CompactVerticalLayout(), BeforeEnterObserver { - private val loginLayout = CompactVerticalLayout() - private val footer = VerticalLayout() - private val header = LogoHeader() - - private val errorLayout = HorizontalLayout() - private val errorMessage = Span() - - private val usernameOrEmail = TextField(null, getTranslation("login.usernameOrEmail")) - private val password = PasswordField(null, getTranslation("login.password")) - - private val submit = Button(getTranslation("login.loginAction")) { login() } - - private lateinit var challenge: String + private val footer = VerticalLayout().apply { + val resetHref = "https://faforever.com/account/password/reset" + val passwordReset = Anchor(resetHref, getTranslation("login.forgotPassword")) + val registerHref = "https://faforever.com/account/register" + val registerAccount = Anchor(registerHref, getTranslation("login.registerAccount")) - init { - header.setTitle(getTranslation("login.welcomeBack")) - add(header) + val links = HorizontalLayout(passwordReset, registerAccount).apply { + addClassNames("pipe-separated") + } - errorLayout.setWidthFull() - errorLayout.addClassName("error") - val errorIcon = FontAwesomeIcon() - errorIcon.addClassNames("fas fa-exclamation-triangle") - errorLayout.add(errorIcon) - errorLayout.add(errorMessage) - errorLayout.isVisible = false - errorLayout.alignItems = FlexComponent.Alignment.CENTER - errorLayout.setVerticalComponentAlignment(FlexComponent.Alignment.CENTER) + add(links) + add(SocialIcons()) + alignItems = FlexComponent.Alignment.CENTER + } + private val header = LogoHeader().apply { + setTitle(getTranslation("login.welcomeBack")) + } - add(errorLayout) + private val errorMessage = Span() + private val errorLayout = HorizontalLayout().apply { + isVisible = false + alignItems = FlexComponent.Alignment.CENTER + setVerticalComponentAlignment(FlexComponent.Alignment.CENTER) + setWidthFull() + addClassName("error") + add(FontAwesomeIcon().apply { addClassNames("fas fa-exclamation-triangle") }) + add(errorMessage) + } - password.addKeyUpListener { + private val usernameOrEmail = TextField(null, getTranslation("login.usernameOrEmail")).apply { + setWidthFull() + } + private val password = PasswordField(null, getTranslation("login.password")).apply { + setWidthFull() + addKeyUpListener { if (it.key.equals(Key.ENTER)) { login() } } + } - usernameOrEmail.setWidthFull() - password.setWidthFull() - submit.setWidthFull() - submit.addThemeVariants(ButtonVariant.LUMO_PRIMARY) - - loginLayout.add(usernameOrEmail, password, submit) - add(loginLayout) - - val footer = footer - - val links = HorizontalLayout() - links.addClassName("pipe-separated") - - val resetHref = "https://faforever.com/account/password/reset" - val passwordReset = Anchor(resetHref, getTranslation("login.forgotPassword")) - val registerHref = "https://faforever.com/account/register" - val registerAccount = Anchor(registerHref, getTranslation("login.registerAccount")) + private val submit = Button(getTranslation("login.loginAction")) { login() }.apply { + setWidthFull() + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + } + private val loginLayout = CompactVerticalLayout(usernameOrEmail, password, submit) - links.add(passwordReset, registerAccount) - footer.add(links) - footer.add(SocialIcons()) - footer.alignItems = FlexComponent.Alignment.CENTER + private lateinit var challenge: String + init { + add(header) + add(errorLayout) + add(loginLayout) add(footer) } fun login() { - val currentRequest = VaadinRequest.getCurrent() - val realIp = currentRequest.getHeader(fafProperties.realIpHeader()) ?: currentRequest.remoteAddr - val ipAddress = IpAddress(realIp) + val ipAddress = vaadinIpService.getRealIp() when (val loginResponse = hydraService.login(challenge, usernameOrEmail.value, password.value, ipAddress)) { is LoginResponse.FailedLogin -> displayErrorMessage(loginResponse.recoverableLoginFailure) is LoginResponse.RejectedLogin -> displayRejectedMessage(loginResponse.unrecoverableLoginFailure) @@ -120,7 +111,8 @@ class LoginView(private val hydraService: HydraService, private val fafPropertie when (loginError) { is LoginResult.UserNoGameOwnership -> { header.setTitle(getTranslation("verification.title")) - errorMessage.text = getTranslation("verification.reason") + " " + fafProperties.accountLinkUrl() + errorMessage.text = getTranslation("verification.reason") + " " + + fafProperties.account().accountLinkUrl() } is LoginResult.TechnicalError -> { diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/registration/ActivateView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/registration/ActivateView.kt new file mode 100644 index 00000000..a5f7a16e --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/registration/ActivateView.kt @@ -0,0 +1,125 @@ +package com.faforever.userservice.ui.view.registration + +import com.faforever.userservice.backend.registration.InvalidRegistrationException +import com.faforever.userservice.backend.registration.RegisteredUser +import com.faforever.userservice.backend.registration.RegistrationService +import com.faforever.userservice.backend.security.VaadinIpService +import com.faforever.userservice.ui.component.FafLogo +import com.faforever.userservice.ui.component.SocialIcons +import com.faforever.userservice.ui.layout.CardLayout +import com.faforever.userservice.ui.layout.CompactVerticalLayout +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.button.ButtonVariant +import com.vaadin.flow.component.dialog.Dialog +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.html.Span +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.component.textfield.PasswordField +import com.vaadin.flow.component.textfield.TextField +import com.vaadin.flow.data.binder.Binder +import com.vaadin.flow.data.value.ValueChangeMode +import com.vaadin.flow.router.BeforeEnterEvent +import com.vaadin.flow.router.BeforeEnterObserver +import com.vaadin.flow.router.Route + +@Route("/register/activate", layout = CardLayout::class) +class ActivateView(private val registrationService: RegistrationService, private val vaadinIpService: VaadinIpService) : + CompactVerticalLayout(), BeforeEnterObserver { + + class PasswordConfirmation { + var password: String = "" + var confirmedPassword: String = "" + } + + private val username = TextField(null, getTranslation("register.username")).apply { + isReadOnly = true + setWidthFull() + } + private val email = TextField(null, getTranslation("register.email")).apply { + isReadOnly = true + setWidthFull() + } + private val password = PasswordField(null, getTranslation("register.password")).apply { + setWidthFull() + valueChangeMode = ValueChangeMode.LAZY + } + private val confirmedPassword = PasswordField(null, getTranslation("register.password.confirm")).apply { + setWidthFull() + valueChangeMode = ValueChangeMode.LAZY + } + private val submit = Button(getTranslation("register.activate")) { activate() }.apply { + isEnabled = false + setWidthFull() + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + } + + private val binder = Binder(PasswordConfirmation::class.java) + + private lateinit var registeredUser: RegisteredUser + + init { + + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("register.activate")) + val formHeader = HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + setId("form-header") + setWidthFull() + } + + add(formHeader) + + add(username, email, password, confirmedPassword, submit) + + val footer = VerticalLayout(SocialIcons()).apply { + alignItems = FlexComponent.Alignment.CENTER + } + + add(footer) + + binder.forField(password) + .asRequired(getTranslation("register.password.required")) + .withValidator({ username -> username.length >= 6 }, getTranslation("register.password.size")) + .bind("password") + + binder.forField(confirmedPassword) + .withValidator( + { confirmedPassword -> confirmedPassword == password.value }, + getTranslation("register.password.match"), + ).bind("confirmedPassword") + + binder.addStatusChangeListener { submit.isEnabled = it.binder.isValid } + } + + private fun activate() { + val validationStatus = binder.validate() + if (validationStatus.hasErrors()) { + return + } + + val ipAddress = vaadinIpService.getRealIp() + + registrationService.activate(registeredUser, ipAddress, password.value) + + val successDialog = Dialog() + successDialog.add(H2(getTranslation("register.activated"))) + successDialog.add(Span(getTranslation("register.activated.details"))) + successDialog.open() + + binder.readBean(null) + } + + override fun beforeEnter(event: BeforeEnterEvent?) { + val possibleToken = event?.location?.queryParameters?.parameters?.get("token")?.get(0) + if (possibleToken != null) { + registeredUser = registrationService.validateRegistrationToken(possibleToken) + username.value = registeredUser.username + email.value = registeredUser.email + } else { + throw InvalidRegistrationException() + } + } +} diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/registration/RegisterView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/registration/RegisterView.kt new file mode 100644 index 00000000..b11955db --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/registration/RegisterView.kt @@ -0,0 +1,150 @@ +package com.faforever.userservice.ui.view.registration + +import com.faforever.userservice.backend.registration.EmailStatus +import com.faforever.userservice.backend.registration.RegistrationService +import com.faforever.userservice.backend.registration.UsernameStatus +import com.faforever.userservice.config.FafProperties +import com.faforever.userservice.ui.component.FafLogo +import com.faforever.userservice.ui.component.SocialIcons +import com.faforever.userservice.ui.layout.CardLayout +import com.faforever.userservice.ui.layout.CompactHorizontalLayout +import com.faforever.userservice.ui.layout.CompactVerticalLayout +import com.vaadin.flow.component.Text +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.button.ButtonVariant +import com.vaadin.flow.component.checkbox.Checkbox +import com.vaadin.flow.component.dialog.Dialog +import com.vaadin.flow.component.html.Anchor +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.html.Span +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.component.textfield.TextField +import com.vaadin.flow.data.binder.Binder +import com.vaadin.flow.data.validator.EmailValidator +import com.vaadin.flow.data.value.ValueChangeMode +import com.vaadin.flow.router.Route + +@Route("/register", layout = CardLayout::class) +class RegisterView(private val registrationService: RegistrationService, fafProperties: FafProperties) : + CompactVerticalLayout() { + + class RegistrationInfo { + var username: String = "" + var email: String = "" + var termsOfService: Boolean = false + var privacyPolicy: Boolean = false + var rules: Boolean = false + } + + private val username = TextField(null, getTranslation("register.username")).apply { + setWidthFull() + valueChangeMode = ValueChangeMode.LAZY + } + private val email = TextField(null, getTranslation("register.email")).apply { + setWidthFull() + valueChangeMode = ValueChangeMode.LAZY + } + private val termsOfService = Checkbox(false) + private val privacyPolicy = Checkbox(false) + private val rules = Checkbox(false) + + private val submit = Button(getTranslation("register.action")) { register() }.apply { + isEnabled = false + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + setWidthFull() + } + + private val binder = Binder(RegistrationInfo::class.java) + + init { + + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("register.action")) + val formHeader = HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + setId("form-header") + setWidthFull() + } + + add(formHeader) + + val readAndAgree = getTranslation("register.readAndAgree") + " " + val termsOfServiceLayout = CompactHorizontalLayout( + termsOfService, + Text(readAndAgree), + Anchor( + fafProperties.account().registration().termsOfServiceUrl(), + getTranslation("register.termsOfService"), + ).apply { addClassName("policy-link") }, + ) + val privacyPolicyLayout = CompactHorizontalLayout( + privacyPolicy, + Text( + readAndAgree, + ), + Anchor( + fafProperties.account().registration().privacyStatementUrl(), + getTranslation("register.privacy"), + ).apply { addClassName("policy-link") }, + ) + val rulesLayout = CompactHorizontalLayout( + rules, + Text(readAndAgree), + Anchor( + fafProperties.account().registration().rulesUrl(), + getTranslation("register.rules"), + ).apply { addClassName("policy-link") }, + ) + + add(username, email, termsOfServiceLayout, privacyPolicyLayout, rulesLayout, submit) + + val footer = VerticalLayout(SocialIcons()).apply { + alignItems = FlexComponent.Alignment.CENTER + } + + add(footer) + + binder.forField(username).asRequired(getTranslation("register.username.required")) + .withValidator({ username -> username[0].isLetter() }, getTranslation("register.username.startsWithLetter")) + .withValidator({ username -> username.length in 3..15 }, getTranslation("register.username.size")) + .withValidator( + { username -> !Regex("[^A-Za-z0-9_-]").containsMatchIn(username) }, + getTranslation("register.username.alphanumeric"), + ).withValidator( + { username -> registrationService.usernameAvailable(username) == UsernameStatus.USERNAME_AVAILABLE }, + 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(termsOfService).asRequired(getTranslation("register.acknowledge.terms")).bind("termsOfService") + + binder.forField(privacyPolicy).asRequired(getTranslation("register.acknowledge.privacy")).bind("privacyPolicy") + + binder.forField(rules).asRequired(getTranslation("register.acknowledge.rules")).bind("rules") + + binder.addStatusChangeListener { submit.isEnabled = it.binder.isValid } + } + + private fun register() { + val validationStatus = binder.validate() + if (validationStatus.hasErrors()) { + return + } + + registrationService.register(username.value, email.value) + + val successDialog = Dialog() + successDialog.add(H2(getTranslation("register.success"))) + successDialog.add(Span(getTranslation("register.success.details", email.value))) + successDialog.open() + + binder.readBean(null) + } +} diff --git a/src/main/mjml/_footer.mjml b/src/main/mjml/_footer.mjml new file mode 100644 index 00000000..b2ba585d --- /dev/null +++ b/src/main/mjml/_footer.mjml @@ -0,0 +1,36 @@ + + + + +

+ www.faforever.com +

+
+
+
diff --git a/src/main/mjml/_header.mjml b/src/main/mjml/_header.mjml new file mode 100644 index 00000000..76842ce2 --- /dev/null +++ b/src/main/mjml/_header.mjml @@ -0,0 +1,9 @@ + + + + + Forged Alliance Forever + + + + diff --git a/src/main/mjml/_style.mjml b/src/main/mjml/_style.mjml new file mode 100644 index 00000000..d4eb0d8e --- /dev/null +++ b/src/main/mjml/_style.mjml @@ -0,0 +1,24 @@ + + + .footer li { + display: inline-block; + padding: 0 1px; + } + + .footer ul { + padding: 0; + list-style-type: none; + } + + .footer a { + text-decoration: none; + } + + a, a:visited { + color: white; + } + + .footer a:hover { + color: #d32a0e; + } + diff --git a/src/main/mjml/activate-account.mjml b/src/main/mjml/activate-account.mjml new file mode 100644 index 00000000..84c1cecf --- /dev/null +++ b/src/main/mjml/activate-account.mjml @@ -0,0 +1,56 @@ + + + + + FAForever account activation + + + + + + + + +

Activate your account

+

Dear {{username}},

+

welcome to the FAForever community. To finish your registration please activate your account and select a + password. +

+
+ + Activate account + + +

Further information will be provided to you after activation.

+
+ +

Thanks,

+

-- The FAForever team

+
+
+
+ + + + +

You don't know where this email is coming from?

+

Someone used your email address in the registration form on faforever.com. If you do not wish to be part of + our community, just ignore or delete this email. No account was created yet. +

+
+
+
+ + + + + + +

If the button above does not work, you need to open this url manually in your browser: + {{activationUrl}} + + + + + + diff --git a/src/main/mjml/dummy/test-account-activation.html b/src/main/mjml/dummy/test-account-activation.html new file mode 100644 index 00000000..0cf9a1c3 --- /dev/null +++ b/src/main/mjml/dummy/test-account-activation.html @@ -0,0 +1,5 @@ +To generate proper html code use the template in src/main/mjml with an mjml parser (e.g. use the IntelliJ plugin for MJML or follow the instructions on https://documentation.mjml.io/#usage +Following variables are available: +username: {{username}} +activationUrl: {{activationUrl}} + diff --git a/src/main/mjml/dummy/test-password-reset.html b/src/main/mjml/dummy/test-password-reset.html new file mode 100644 index 00000000..f3c11b73 --- /dev/null +++ b/src/main/mjml/dummy/test-password-reset.html @@ -0,0 +1,4 @@ +To generate proper html code use the template in src/main/mjml with an mjml parser (e.g. use the IntelliJ plugin for MJML or follow the instructions on https://documentation.mjml.io/#usage +Following variables are available: +username: {{username}} +passwordResetUrl: {{passwordResetUrl}} diff --git a/src/main/mjml/dummy/test-welcome-to-faf.html b/src/main/mjml/dummy/test-welcome-to-faf.html new file mode 100644 index 00000000..758df26a --- /dev/null +++ b/src/main/mjml/dummy/test-welcome-to-faf.html @@ -0,0 +1,3 @@ +To generate proper html code use the template in src/main/mjml with an mjml parser (e.g. use the IntelliJ plugin for MJML or follow the instructions on https://documentation.mjml.io/#usage +Following variables are available: +username: {{username}} diff --git a/src/main/mjml/password-reset.mjml b/src/main/mjml/password-reset.mjml new file mode 100644 index 00000000..7011e695 --- /dev/null +++ b/src/main/mjml/password-reset.mjml @@ -0,0 +1,50 @@ + + + + + Password reset + + + + + + + + +

Password reset

+

Dear {{username}},

+

a password reset was requested for your account. Click on the button to reset your password now. +

+
+ + Reset password + + +

Thanks,

+

-- The FAForever team

+
+
+
+ + + + +

If you did not request a new password or changed your mind, you can just delete this email. +

+
+
+
+ + + + + + +

If the button above does not work, you need to open this url manually in your browser: + {{passwordResetUrl}} + + + + + + diff --git a/src/main/mjml/readme.md b/src/main/mjml/readme.md new file mode 100644 index 00000000..9c80d55b --- /dev/null +++ b/src/main/mjml/readme.md @@ -0,0 +1,10 @@ +This folder contains emails in the MailJet Markup language (see https://mjml.io/). + +**They need to be processed with the MJML tools (e.g. IDE integrations). There is no automation currently.** + +Once "compiled", the resulting html needs to be put into the right configuration places. +As of now these are (by default): + +* /config/mail/account-activation.html +* /config/mail/welcome.html +* /config/mail/password-reset.html diff --git a/src/main/mjml/welcome-to-faf.mjml b/src/main/mjml/welcome-to-faf.mjml new file mode 100644 index 00000000..cce70fbc --- /dev/null +++ b/src/main/mjml/welcome-to-faf.mjml @@ -0,0 +1,104 @@ + + + + + Password reset + + + + + + + + +

Welcome, {{username}}

+

you just joined FAF, how great is that! We're a rather small community so every new member is precious to + us. +

+

This email contains the next steps to get you started.

+
+ + +
+
+ + + + +

Link your account to Steam

+

Before you can play games, we need to verify that you own the game. Please login to our website and link + your account to Steam. Make sure your Steam profile and game list are public (you can follow this guide). +

+
+ + Link to Steam + + +
+
+ + + + +

Download our Client

+

You will need to install our client to chat with other player launch games, download maps & mods.. +

+
+ + Download FAF client + + +
+
+ + + + +

Get help

+

Don't be afraid to ask for help. FAF isn't always straightforward. But we have plenty of resources to help you out.

+
+
+
+ + + + + Open FAQ + + + + + Open Wiki + + + + + Open Forum + + + + + + + + +

We hope this gives you a head start. See you on the battlefield!

+

-- The FAForever team

+
+
+
+ + + + +

Should you ever want to delete your account, please send an email with your request to + admin@faforever.com. +

+
+
+
+ + +
+
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8d3a9ea7..c91ec088 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,10 +1,33 @@ faf: environment: ${FAF_ENVIRONMENT} real-ip-header: ${REAL_IP_HEADER:X-Real-Ip} - password-reset-url: ${PASSWORD_RESET_URL:https://faforever.com/account/password/reset} - register-account-url: ${REGISTER_ACCOUNT_URL:https://faforever.com/account/register} - account-link-url: ${ACCOUNT_LINK_URL:https://www.faforever.com/account/link} hydra-base-url: ${HYDRA_BASE_ADMIN_URL:http://localhost:4445} + account: + password-reset-url: ${PASSWORD_RESET_URL:`https://faforever.com/account/password/reset`} + register-account-url: ${REGISTER_ACCOUNT_URL:`https://faforever.com/account/register`} + account-link-url: ${ACCOUNT_LINK_URL:`https://www.faforever.com/account/link`} + registration: + activation-url-format: ${ACTIVATION_URL_FORMAT:http://localhost:8080/register/activate?token=%s} + subject: ${REGISTRATION_EMAIL_SUBJECT:FAF user registration} + activation-mail-template-path: ${ACCOUNT_ACTIVATION_MAIL_TEMPLATE_PATH:/config/mail/account-activation.html} + welcome-subject: ${WELCOME_MAIL_SUBJECT:Welcome to FAF} + welcome-mail-template-path: ${WELCOME_MAIL_TEMPLATE_PATH:/config/mail/welcome-to-faf.html} + terms-of-service-url: ${FAF_TERMS_OF_SERVICE:https://faforever.com/tos} + privacy-statement-url: ${FAF_PRIVACY_STATEMENT:https://faforever.com/privacy} + rules-url: ${FAF_RULES:https://faforever.com/rules} + password-reset: + password-reset-url-format: ${PASSWORD_RESET_URL_FORMAT:http://localhost:8080/account/password/confirmReset?token=%s} + subject: ${PASSWORD_RESET_EMAIL_SUBJECT:FAF password reset} + mail-template-path: ${PASSWORD_RESET_MAIL_TEMPLATE_PATH:/config/mail/password-reset.html} + username: + minimum-days-between-username-change: ${MIN_DAYS_BETWEEN_NAME_CHANGE:30} + username-reservation-time-in-months: ${NAME_RESERVATION_MONTHS:6} + recaptcha: + enabled: ${RECAPTCHA_ENABLED:true} + secret: ${RECAPTCHA_SECRET:6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe} + site-key: ${RECAPTCHA_SITE_KEY:6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI} + jwt: + secret: ${JWT_SECRET:banana} lobby: secret: ${LOBBY_SECRET:banana} access-uri: ${LOBBY_URL:ws://localhost:8003} @@ -13,6 +36,10 @@ faf: secret: ${IRC_SECRET:banana} token-ttl: ${IRC_TOKEN_TTL:300} +mailjet: + api-key: ${MAILJET_API_KEY} + api-secret: ${MAILJET_API_SECRET} + security: failed-login-account-threshold: ${FAILED_LOGIN_ACCOUNT_THRESHOLD:5} failed-login-attempt-threshold: ${FAILED_LOGIN_ATTEMPT_THRESHOLD:10} @@ -20,12 +47,23 @@ security: failed-login-days-to-check: ${FAILED_LOGIN_DAYS_TO_CHECK:1} quarkus: - http.cors: true log: level: INFO rest-client: faf-ory-hydra: url: ${HYDRA_BASE_ADMIN_URL:http://localhost:4445} + recaptcha: + url: https://www.google.com/recaptcha/api + mailer: + host: ${MAIL_HOST:localhost} + port: ${MAIL_PORT:0} + username: ${MAIL_USERNAME:mailer} + password: ${MAIL_PASSWORD:banana} + start-tls: OPTIONAL + ssl: true + login: REQUIRED + from: ${EMAIL_FROM_ADDRESS:admin@faforever.com} + mock: ${MOCK_EMAIL:true} devservices: enabled: false datasource: @@ -36,18 +74,30 @@ quarkus: url: ${DB_URL:jdbc:mariadb://localhost:3306/faf?ssl=false} application: name: faf-user-service + management: + enabled: true "%dev": quarkus: + live-reload: + instrumentation: true http: cors: origins: "/.*/" + mailer: + mock: true log: category: "com.faforever": level: DEBUG faf: environment: ${FAF_ENVIRONMENT:dev} + account: + registration: + activation-mail-template-path: ${ACCOUNT_ACTIVATION_MAIL_TEMPLATE_PATH:../../../../src/main/mjml/dummy/test-account-activation.html} + welcome-mail-template-path: ${WELCOME_MAIL_TEMPLATE_PATH:../../../../src/main/mjml/dummy/test-welcome-to-faf.html} + password-reset: + mail-template-path: ${PASSWORD_RESET_MAIL_TEMPLATE_PATH:../../../../src/main/mjml/dummy/test-password-reset.html} irc: fixed: users: diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 0eb8900c..749ae581 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -56,3 +56,34 @@ ban.permanent=Never ban.reason=Reason: ban.appeal=You may appeal your ban by emailing moderation@faforever.com error.internal=An internal error has occurred +registration=Registration +registration.title=Registration +register.action=Create Account +register.success=Account Registered +register.success.details=You have successfully registered please use the link sent to {0} to activate your account +register.activate=Activate Your Account +register.activated=Account Activated +register.activated.details=You have successfully activated your account. Please note that in order to play online you will need prove ownership of the game +register.username=Username +register.username.required=Username required +register.username.taken=Username is taken, please choose another username +register.username.size=Username must be between 3 and 15 characters +register.username.startsWithLetter=Username must start with a letter +register.username.alphanumeric=Username can only contain letters, numbers, underscores, and dashes +register.password=Password +register.password.required=Password required +register.password.confirm=Confirm Password +register.password.size=Password must be at least 6 characters +register.password.match=Passwords must match +register.email=Email +register.email.taken=Email is already registered to another user +register.email.blacklisted=The supplied email domain is blacklisted please use another email +register.email.invalid=Email is not a valid address +register.readAndAgree=I have read and agree to the +register.termsOfService=Terms of Service +register.privacy=Privacy Statement +register.rules=FAF Rules +register.acknowledge.terms=You must accept the terms of service to create an account +register.acknowledge.privacy=You must accept the privacy policy to create an account +register.acknowledge.rules=You must accept the FAF rules to create an account +register.technicalError=We encountered a technical error during registration. Please restart the registration from the beginning. \ No newline at end of file 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 3f4b3780..9f972948 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt @@ -1,5 +1,8 @@ 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.PasswordEncoder import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest 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 11f05b88..cae04866 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/hydra/HydraServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/hydra/HydraServiceTest.kt @@ -1,9 +1,9 @@ package com.faforever.userservice.backend.hydra import com.faforever.userservice.backend.domain.IpAddress -import com.faforever.userservice.backend.domain.LoginResult -import com.faforever.userservice.backend.domain.LoginService 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.OAuthScope import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest diff --git a/src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt new file mode 100644 index 00000000..a398c668 --- /dev/null +++ b/src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt @@ -0,0 +1,143 @@ +package com.faforever.userservice.backend.registration + +import com.faforever.userservice.backend.domain.DomainBlacklistRepository +import com.faforever.userservice.backend.domain.IpAddress +import com.faforever.userservice.backend.domain.NameRecordRepository +import com.faforever.userservice.backend.domain.User +import com.faforever.userservice.backend.domain.UserRepository +import com.faforever.userservice.backend.security.FafTokenService +import com.faforever.userservice.config.FafProperties +import io.quarkus.mailer.MockMailbox +import io.quarkus.test.InjectMock +import io.quarkus.test.junit.QuarkusTest +import jakarta.inject.Inject +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.`is` +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@QuarkusTest +class RegistrationServiceTest { + + companion object { + private const val username = "someUsername" + private const val email = "some@email.com" + private const val password = "somePassword" + private val ipAddress = IpAddress("127.0.0.1") + + private val user = User(1, username, password, email, null) + } + + @Inject + private lateinit var registrationService: RegistrationService + + @Inject + private lateinit var mailbox: MockMailbox + + @Inject + private lateinit var fafProperties: FafProperties + + @InjectMock + private lateinit var userRepository: UserRepository + + @InjectMock + private lateinit var nameRecordRepository: NameRecordRepository + + @InjectMock + private lateinit var domainBlacklistRepository: DomainBlacklistRepository + + @InjectMock + private lateinit var fafTokenService: FafTokenService + + @BeforeEach + fun setup() { + mailbox.clear() + } + + @Test + fun registerSuccess() { + registrationService.register(username, email) + + val sent = mailbox.getMailMessagesSentTo(email) + assertThat(sent, hasSize(1)) + val actual = sent[0] + assertThat(actual.subject, `is`(fafProperties.account().registration().subject())) + } + + @Test + fun registerUsernameTaken() { + whenever(userRepository.existsByUsername(anyString())).thenReturn(true) + + assertThrows { registrationService.register(username, email) } + } + + @Test + fun registerUsernameReserved() { + whenever(nameRecordRepository.existsByPreviousNameAndChangeTimeAfter(anyString(), any())).thenReturn(true) + + assertThrows { registrationService.register(username, email) } + } + + @Test + fun registerEmailTaken() { + whenever(userRepository.existsByEmail(anyString())).thenReturn(true) + + assertThrows { registrationService.register(username, email) } + } + + @Test + fun registerEmailBlacklisted() { + whenever(domainBlacklistRepository.existsByDomain(anyString())).thenReturn(true) + + assertThrows { registrationService.register(username, email) } + } + + @Test + fun activateSuccess() { + registrationService.activate(RegisteredUser(username, email), ipAddress, password) + + verify(userRepository).persist(any()) + } + + @Test + fun activateUsernameTaken() { + whenever(userRepository.existsByUsername(anyString())).thenReturn(true) + + assertThrows { + registrationService.activate(RegisteredUser(username, email), ipAddress, password) + } + } + + @Test + fun activateUsernameReserved() { + whenever(nameRecordRepository.existsByPreviousNameAndChangeTimeAfter(anyString(), any())).thenReturn(true) + + assertThrows { + registrationService.activate(RegisteredUser(username, email), ipAddress, password) + } + } + + @Test + fun activateEmailTaken() { + whenever(userRepository.existsByEmail(anyString())).thenReturn(true) + + assertThrows { + registrationService.activate(RegisteredUser(username, email), ipAddress, password) + } + } + + @Test + fun activateEmailBlacklisted() { + whenever(domainBlacklistRepository.existsByDomain(anyString())).thenReturn(true) + + assertThrows { + registrationService.activate(RegisteredUser(username, email), ipAddress, password) + } + } +} diff --git a/src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt new file mode 100644 index 00000000..19bf9579 --- /dev/null +++ b/src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt @@ -0,0 +1,57 @@ +package com.faforever.userservice.backend.security + +import io.quarkus.test.junit.QuarkusTest +import jakarta.inject.Inject +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.hasEntry +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.time.Duration + +@QuarkusTest +class FAFTokenServiceTest { + + @Inject + private lateinit var fafTokenService: FafTokenService + + @Test + fun testTokenCreationAndParsing() { + val attributes = mapOf("username" to "test", "email" to "test@test.com") + val token = fafTokenService.createToken(FafTokenType.REGISTRATION, Duration.ofSeconds(60), attributes) + val decodedAttributes = fafTokenService.getTokenClaims(FafTokenType.REGISTRATION, token) + assertThat(decodedAttributes, hasEntry("username", "test")) + assertThat(decodedAttributes, hasEntry("email", "test@test.com")) + } + + @Test + fun testTokenFailsWithWrongType() { + val attributes = mapOf("username" to "test", "email" to "test@test.com") + val token = fafTokenService.createToken(FafTokenType.REGISTRATION, Duration.ofSeconds(60), attributes) + assertThrows { + fafTokenService.getTokenClaims( + FafTokenType.LINK_TO_STEAM, + token, + ) + } + } + + @Test + fun testTokenFailsExpired() { + val attributes = mapOf("username" to "test", "email" to "test@test.com") + val token = fafTokenService.createToken(FafTokenType.REGISTRATION, Duration.ofSeconds(-60), attributes) + assertThrows { + fafTokenService.getTokenClaims( + FafTokenType.REGISTRATION, + token, + ) + } + } + + @Test + fun testTokenFailsCustomAction() { + val attributes = mapOf("username" to "test", "email" to "test@test.com", "action" to "bad_action") + assertThrows { + fafTokenService.createToken(FafTokenType.REGISTRATION, Duration.ofSeconds(60), attributes) + } + } +} diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 7118b711..a089b52e 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -1,4 +1,11 @@ faf: + account: + password-reset: + mail-template-path: src/test/resources/mail/password-reset.html + registration: + activation-mail-template-path: src/test/resources/mail/account-activation.html + welcome-mail-template-path: src/test/resources/mail/welcome-to-faf.html + irc: fixed: users: diff --git a/src/test/resources/mail/account-activation.html b/src/test/resources/mail/account-activation.html new file mode 100644 index 00000000..cb75345a --- /dev/null +++ b/src/test/resources/mail/account-activation.html @@ -0,0 +1,3 @@ +{{username}} + +{{activationUrl}} \ No newline at end of file diff --git a/src/test/resources/mail/password-reset.html b/src/test/resources/mail/password-reset.html new file mode 100644 index 00000000..d25c3b77 --- /dev/null +++ b/src/test/resources/mail/password-reset.html @@ -0,0 +1,3 @@ +{{username}} + +{{passwordResetUrl}} \ No newline at end of file diff --git a/src/test/resources/mail/welcome-to-faf.html b/src/test/resources/mail/welcome-to-faf.html new file mode 100644 index 00000000..1f2c7177 --- /dev/null +++ b/src/test/resources/mail/welcome-to-faf.html @@ -0,0 +1 @@ +{{username}} \ No newline at end of file