Skip to content

Commit

Permalink
Add password reset
Browse files Browse the repository at this point in the history
  • Loading branch information
Brutus5000 committed Dec 31, 2024
1 parent e5eb344 commit 393bdbe
Show file tree
Hide file tree
Showing 37 changed files with 1,052 additions and 114 deletions.
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ max_line_length=120
end_of_line=lf
ij_any_line_comment_add_space = true
ij_any_line_comment_at_first_column = false

# Disable wildcard imports entirely
ij_kotlin_name_count_to_use_star_import = 2147483647
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
ij_kotlin_packages_to_use_import_on_demand = unset
2 changes: 1 addition & 1 deletion frontend/themes/faforever/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ header {

.main-card {
border-radius: 2px;
max-width: 30rem;
max-width: max(80%, 30rem);
margin: 3rem auto;
background-color: rgba(220, 220, 220, 0.8);
box-shadow: 3px 4px 5px 5px rgba(0, 0, 0, 0.2);
Expand Down
Binary file modified src/main/bundles/dev.bundle
Binary file not shown.
8 changes: 7 additions & 1 deletion src/main/kotlin/com/faforever/userservice/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ package com.faforever.userservice

import com.vaadin.flow.component.page.AppShellConfigurator
import com.vaadin.flow.theme.Theme
import io.quarkus.runtime.StartupEvent
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes

@Theme("faforever")
@ApplicationScoped
class AppConfig : AppShellConfigurator
class AppConfig : AppShellConfigurator {
fun onStart(@Observes event: StartupEvent) {
System.setProperty("vaadin.copilot.enable", "false")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.faforever.userservice.backend.account

class InvalidRecoveryException(message: String) : Exception(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.faforever.userservice.backend.account

class InvalidRegistrationException : Exception()
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.faforever.userservice.backend.login
package com.faforever.userservice.backend.account

import com.faforever.userservice.backend.domain.AccountLinkRepository
import com.faforever.userservice.backend.domain.Ban
Expand All @@ -9,6 +9,7 @@ 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.hydra.HydraService
import com.faforever.userservice.backend.security.PasswordEncoder
import io.smallrye.config.ConfigMapping
import jakarta.enterprise.context.ApplicationScoped
Expand Down Expand Up @@ -56,6 +57,8 @@ interface LoginService {
fun findUserBySubject(subject: String): User?

fun login(usernameOrEmail: String, password: String, ip: IpAddress, requiresGameOwnership: Boolean): LoginResult

fun resetPassword(userId: Int, newPassword: String)
}

@ApplicationScoped
Expand All @@ -66,6 +69,7 @@ class LoginServiceImpl(
private val accountLinkRepository: AccountLinkRepository,
private val passwordEncoder: PasswordEncoder,
private val banRepository: BanRepository,
private val hydraService: HydraService,
) : LoginService {
companion object {
private val LOG: Logger = LoggerFactory.getLogger(LoginServiceImpl::class.java)
Expand Down Expand Up @@ -96,7 +100,7 @@ class LoginServiceImpl(

if (requiresGameOwnership && !accountLinkRepository.hasOwnershipLink(user.id!!)) {
LOG.debug(
"Lobby login blocked for user '{}' because of missing game ownership verification",
"Lobby account blocked for user '{}' because of missing game ownership verification",
usernameOrEmail,
)
return LoginResult.UserNoGameOwnership
Expand Down Expand Up @@ -125,7 +129,7 @@ class LoginServiceImpl(
val accountsAffected = failedAttemptsSummary.accountsAffected
val totalFailedAttempts = failedAttemptsSummary.totalAttempts

LOG.debug("Failed login attempts for IP address '{}': {}", ip, failedAttemptsSummary)
LOG.debug("Failed account attempts for IP address '{}': {}", ip, failedAttemptsSummary)

return if (accountsAffected > securityProperties.failedLoginAccountThreshold() ||
totalFailedAttempts > securityProperties.failedLoginAttemptThreshold()
Expand All @@ -138,12 +142,23 @@ class LoginServiceImpl(
LOG.debug("IP '$ip' is trying again to early -> throttle it")
true
} else {
LOG.debug("IP '$ip' triggered a threshold but last login does not hit throttling time")
LOG.debug("IP '$ip' triggered a threshold but last account does not hit throttling time")
false
}
} else {
LOG.trace("IP '$ip' did not hit a throttling limit")
false
}
}

override fun resetPassword(userId: Int, newPassword: String) {
userRepository.findById(userId)!!.apply {
password = passwordEncoder.encode(newPassword)
userRepository.persist(this)
}

hydraService.revokeConsentRequest(userId.toString())

LOG.info("Password for user id {}} has been reset", userId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.faforever.userservice.backend.account

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.steam.SteamService
import com.faforever.userservice.config.FafProperties
import jakarta.enterprise.context.ApplicationScoped
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.Duration

@ApplicationScoped
class RecoveryService(
private val fafProperties: FafProperties,
private val metricHelper: MetricHelper,
private val userRepository: UserRepository,
private val fafTokenService: FafTokenService,
private val steamService: SteamService,
private val emailService: EmailService,
private val loginService: LoginService,
) {
enum class Type {
EMAIL,
STEAM,
}

companion object {
private val LOG: Logger = LoggerFactory.getLogger(RegistrationService::class.java)
private const val KEY_USER_ID = "id"
}

fun requestPasswordResetViaEmail(usernameOrEmail: String) {
metricHelper.incrementPasswordResetViaEmailRequestCounter()
val user = userRepository.findByUsernameOrEmail(usernameOrEmail)

if (user == null) {
metricHelper.incrementPasswordResetViaEmailFailedCounter()
LOG.info("No user found for recovery with username/email: {}", usernameOrEmail)
} else {
val token = fafTokenService.createToken(
fafTokenType = FafTokenType.PASSWORD_RESET,
lifetime = Duration.ofSeconds(fafProperties.account().passwordReset().linkExpirationSeconds()),
attributes = mapOf(KEY_USER_ID to user.id.toString()),
)
val passwordResetUrl = fafProperties.account().passwordReset().passwordResetUrlFormat().format(token)
emailService.sendPasswordResetMail(user.username, user.email, passwordResetUrl)
metricHelper.incrementPasswordResetViaEmailSentCounter()
}
}

fun buildSteamLoginUrl() =
steamService.buildLoginUrl(
redirectUrl =
fafProperties.account().passwordReset().passwordResetUrlFormat().format("STEAM"),
)

fun parseRecoveryHttpRequest(parameters: Map<String, List<String>>): Pair<Type, User?> {
// At first glance it may seem strange that a service is parsing http request parameters,
// but the parameters of the request are determined by this service itself in the request reset phase!
val token = parameters["token"]?.first()
val steamId = steamService.parseSteamIdFromRequestParameters(parameters)

return when {
steamId != null -> Type.STEAM to steamService.findUserBySteamId(steamId).also { user ->
if (user == null) metricHelper.incrementPasswordResetViaSteamFailedCounter()
}

token != null -> Type.EMAIL to extractUserFromEmailRecoveryToken(token)
else -> {
metricHelper.incrementPasswordResetViaEmailFailedCounter()
throw InvalidRecoveryException("Could not extract recovery type or user from HTTP request")
}
}
}

private fun extractUserFromEmailRecoveryToken(emailRecoveryToken: String): User {
val claims = try {
fafTokenService.getTokenClaims(FafTokenType.PASSWORD_RESET, emailRecoveryToken)
} catch (exception: Exception) {
metricHelper.incrementPasswordResetViaEmailFailedCounter()
LOG.error("Unable to extract claims", exception)
throw InvalidRecoveryException("Unable to extract claims from token")
}

val userId = claims[KEY_USER_ID]

if (userId.isNullOrBlank()) {
metricHelper.incrementPasswordResetViaEmailFailedCounter()
throw InvalidRecoveryException("No user id found in token claims")
}

val user = userRepository.findById(userId.toInt())

if (user == null) {
metricHelper.incrementPasswordResetViaEmailFailedCounter()
throw InvalidRecoveryException("User with id $userId not found")
}

return user
}

fun resetPassword(type: Type, userId: Int, newPassword: String) {
loginService.resetPassword(userId, newPassword)

when (type) {
Type.EMAIL -> metricHelper.incrementPasswordResetViaEmailDoneCounter()
Type.STEAM -> metricHelper.incrementPasswordResetViaSteamDoneCounter()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.faforever.userservice.backend.registration
package com.faforever.userservice.backend.account

import com.faforever.userservice.backend.domain.DomainBlacklistRepository
import com.faforever.userservice.backend.domain.IpAddress
Expand Down Expand Up @@ -46,14 +46,13 @@ class RegistrationService(
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()
metricHelper.incrementUserRegistrationCounter()
}

private fun sendActivationEmail(username: String, email: String) {
Expand All @@ -69,23 +68,6 @@ class RegistrationService(
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)
Expand Down Expand Up @@ -113,9 +95,8 @@ class RegistrationService(
}

fun validateRegistrationToken(registrationToken: String): RegisteredUser {
val claims: Map<String, String>
try {
claims = fafTokenService.getTokenClaims(FafTokenType.REGISTRATION, registrationToken)
val claims = try {
fafTokenService.getTokenClaims(FafTokenType.REGISTRATION, registrationToken)
} catch (exception: Exception) {
LOG.error("Unable to extract claims", exception)
throw InvalidRegistrationException()
Expand Down Expand Up @@ -146,7 +127,7 @@ class RegistrationService(
userRepository.persist(user)

LOG.info("User has been activated: {}", user)
metricHelper.userActivationCounter.increment()
metricHelper.incrementUserActivationCounter()

emailService.sendWelcomeToFafMail(username, email)

Expand Down
18 changes: 15 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 @@ -18,9 +18,9 @@ data class User(
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int? = null,
@Column(name = "login")
val username: String,
val password: String,
val email: String,
var username: String,
var password: String,
var email: String,
val ip: String?,
) : PanacheEntityBase {

Expand Down Expand Up @@ -79,6 +79,18 @@ class UserRepository : PanacheRepositoryBase<User, Int> {
fun existsByUsername(username: String): Boolean = count("username = ?1", username) > 0

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

fun findBySteamId(steamId: String): User? =
getEntityManager().createNativeQuery(
"""
SELECT account.*
FROM account
INNER JOIN service_links ON account.id = service_links.user_id
WHERE type = 'STEAM' and service_id = :steamId
""".trimIndent(),
User::class.java,
).setParameter("steamId", steamId)
.resultList.firstOrNull() as User?
}

@ApplicationScoped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ interface HydraClient {
@Path("/oauth2/auth/requests/consent")
fun getConsentRequest(@QueryParam("consent_challenge") @NotBlank challenge: String): ConsentRequest

// accepting login request more than once throws HTTP 409 - Conflict
// accepting account request more than once throws HTTP 409 - Conflict
@PUT
@Path("/oauth2/auth/requests/login/accept")
fun acceptLoginRequest(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package com.faforever.userservice.backend.hydra

import com.faforever.userservice.backend.account.LoginResult
import com.faforever.userservice.backend.account.LoginService
import com.faforever.userservice.backend.domain.IpAddress
import com.faforever.userservice.backend.domain.UserRepository
import com.faforever.userservice.backend.login.LoginResult
import com.faforever.userservice.backend.login.LoginService
import com.faforever.userservice.backend.security.OAuthScope
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.transaction.Transactional
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import sh.ory.hydra.model.AcceptConsentRequest
import sh.ory.hydra.model.AcceptLoginRequest
import sh.ory.hydra.model.ConsentRequest
Expand Down Expand Up @@ -48,6 +50,8 @@ class HydraService(
private val userRepository: UserRepository,
) {
companion object {
private val LOG: Logger = LoggerFactory.getLogger(HydraService::class.java)

private const val HYDRA_ERROR_USER_BANNED = "user_banned"
private const val HYDRA_ERROR_NO_OWNERSHIP_VERIFICATION = "ownership_not_verified"
private const val HYDRA_ERROR_TECHNICAL_ERROR = "technical_error"
Expand Down Expand Up @@ -171,4 +175,20 @@ class HydraService(
val redirectResponse = hydraClient.rejectConsentRequest(challenge, GenericError("scope_denied"))
return RedirectTo(redirectResponse.redirectTo)
}

fun revokeConsentRequest(subject: String, client: String? = null) {
LOG.info(
"Revoking consent sessions for subject `{}` on client `{}`",
subject,
client ?: "all",
)
val response = hydraClient.revokeRefreshTokens(
subject = subject,
all = client == null,
client = client,
)
if (response.status != 204) {
LOG.error("Revoking tokens from Hydra failed for request (subject={}, client={})", subject, client)
}
}
}
Loading

0 comments on commit 393bdbe

Please sign in to comment.