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

Add account registration and activation #147

Merged
merged 15 commits into from
Nov 30, 2023
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions frontend/themes/faforever/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -170,3 +171,7 @@ a:hover {
.tooltip:hover .tooltiptext {
visibility: visible;
}

.policy-link {
padding-left: 5px;
}
2 changes: 2 additions & 0 deletions src/main/kotlin/com/faforever/userservice/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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<DomainBlacklist, String> {
fun existsByDomain(domain: String): Boolean = count("domain = ?1", domain) > 0
}
Original file line number Diff line number Diff line change
@@ -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<NameRecord, Int> {
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
}
10 changes: 7 additions & 3 deletions src/main/kotlin/com/faforever/userservice/backend/domain/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -75,10 +75,14 @@ class UserRepository : PanacheRepositoryBase<User, Int> {
Permission::class.java,
).setParameter("userId", userId)
.resultList as List<Permission>

fun existsByUsername(username: String): Boolean = count("username = ?1", username) > 0

fun existsByEmail(email: String): Boolean = count("email = ?1", email) > 0
}

@ApplicationScoped
class AccountLinkRepository : PanacheRepositoryBase<AccountLink, String> {
fun hasOwnershipLink(userId: Int): Boolean =
find("userId = ?1 and ownership", userId).firstResult() != null
count("userId = ?1 and ownership", userId) > 0
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<String>

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<String>) {
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, String>): 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,
),
)
}
Original file line number Diff line number Diff line change
@@ -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),
)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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) =
Expand All @@ -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 {
Expand Down