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 password reset #371

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -146,4 +150,15 @@ class LoginServiceImpl(
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,117 @@
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(RecoveryService::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()
LOG.debug("Extracted token: {}", token)

val steamId = steamService.parseSteamIdFromRequestParameters(parameters)
LOG.debug("Extracted Steam id: {}", steamId)

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 login.*
FROM login
INNER JOIN service_links ON login.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
@@ -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
Loading