Skip to content

Commit

Permalink
Add account registration and activation (#147)
Browse files Browse the repository at this point in the history
Co-authored-by: Brutus5000 <Brutus5000@gmx.net>
  • Loading branch information
Sheikah45 and Brutus5000 authored Nov 30, 2023
1 parent 5031067 commit d599d2a
Show file tree
Hide file tree
Showing 47 changed files with 1,826 additions and 106 deletions.
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
Loading

0 comments on commit d599d2a

Please sign in to comment.