diff --git a/.editorconfig b/.editorconfig index 76d8b6ca..00abbc36 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/frontend/themes/faforever/styles.css b/frontend/themes/faforever/styles.css index 7fd6850d..31f9cc51 100644 --- a/frontend/themes/faforever/styles.css +++ b/frontend/themes/faforever/styles.css @@ -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); diff --git a/src/main/bundles/dev.bundle b/src/main/bundles/dev.bundle index fc7298ed..9ffb2c2a 100644 Binary files a/src/main/bundles/dev.bundle and b/src/main/bundles/dev.bundle differ diff --git a/src/main/kotlin/com/faforever/userservice/AppConfig.kt b/src/main/kotlin/com/faforever/userservice/AppConfig.kt index 4827a5ac..13be6501 100644 --- a/src/main/kotlin/com/faforever/userservice/AppConfig.kt +++ b/src/main/kotlin/com/faforever/userservice/AppConfig.kt @@ -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") + } +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/account/InvalidRecoveryException.kt b/src/main/kotlin/com/faforever/userservice/backend/account/InvalidRecoveryException.kt new file mode 100644 index 00000000..0e7ea0d7 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/account/InvalidRecoveryException.kt @@ -0,0 +1,3 @@ +package com.faforever.userservice.backend.account + +class InvalidRecoveryException(message: String) : Exception(message) diff --git a/src/main/kotlin/com/faforever/userservice/backend/account/InvalidRegistrationException.kt b/src/main/kotlin/com/faforever/userservice/backend/account/InvalidRegistrationException.kt new file mode 100644 index 00000000..47f0bab8 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/account/InvalidRegistrationException.kt @@ -0,0 +1,3 @@ +package com.faforever.userservice.backend.account + +class InvalidRegistrationException : Exception() diff --git a/src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt b/src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt similarity index 87% rename from src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt rename to src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt index 00c8c56a..26c749c9 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt @@ -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 @@ -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 @@ -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 @@ -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) @@ -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 @@ -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() @@ -138,7 +142,7 @@ 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 { @@ -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) + } } diff --git a/src/main/kotlin/com/faforever/userservice/backend/account/RecoveryService.kt b/src/main/kotlin/com/faforever/userservice/backend/account/RecoveryService.kt new file mode 100644 index 00000000..f6266cd0 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/account/RecoveryService.kt @@ -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>): Pair { + // 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() + } + } +} diff --git a/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt b/src/main/kotlin/com/faforever/userservice/backend/account/RegistrationService.kt similarity index 83% rename from src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt rename to src/main/kotlin/com/faforever/userservice/backend/account/RegistrationService.kt index 03f1e1df..7c33a27b 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/account/RegistrationService.kt @@ -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 @@ -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) { @@ -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) @@ -113,9 +95,8 @@ class RegistrationService( } fun validateRegistrationToken(registrationToken: String): RegisteredUser { - val claims: Map - 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() @@ -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) 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 747549a9..bf4afbd4 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/domain/User.kt @@ -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 { @@ -79,6 +79,18 @@ class UserRepository : PanacheRepositoryBase { 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 diff --git a/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraClient.kt b/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraClient.kt index 3e4107ae..54886dc8 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraClient.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraClient.kt @@ -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( 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 b9b4981d..79a4a4f2 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt @@ -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 @@ -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" @@ -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) + } + } } diff --git a/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt b/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt index cfb036c9..26ed1e9d 100644 --- a/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt +++ b/src/main/kotlin/com/faforever/userservice/backend/metrics/MetricHelper.kt @@ -1,89 +1,107 @@ 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) { +class MetricHelper(private val 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 PASSWORD_RESET_COUNT = "user.password.reset.count" private const val STEP_TAG = "step" - private const val MODE_TAG = "step" + private const val MODE_TAG = "mode" } // User Registration Counters - val userRegistrationCounter: Counter = meterRegistry.counter( - USER_REGISTRATIONS_COUNT, - STEP_TAG, - "registration", - ) - val userActivationCounter: Counter = meterRegistry.counter( + fun incrementUserRegistrationCounter() { + meterRegistry.counter( + USER_REGISTRATIONS_COUNT, + STEP_TAG, + "registration", + ).increment() + } + + fun incrementUserActivationCounter() = meterRegistry.counter( USER_REGISTRATIONS_COUNT, STEP_TAG, "activation", - ) - val userSteamLinkRequestedCounter: Counter = meterRegistry.counter( + ).increment() + + fun incrementUserSteamLinkRequestedCounter() = meterRegistry.counter( USER_REGISTRATIONS_COUNT, STEP_TAG, "steamLinkRequested", - ) - val userSteamLinkDoneCounter: Counter = meterRegistry.counter( + ).increment() + + fun incrementUserSteamLinkDoneCounter() = meterRegistry.counter( USER_REGISTRATIONS_COUNT, STEP_TAG, "steamLinkDone", - ) - val userSteamLinkFailedCounter: Counter = meterRegistry.counter( + ).increment() + + fun incrementUserSteamLinkFailedCounter() = meterRegistry.counter( USER_REGISTRATIONS_COUNT, STEP_TAG, "steamLinkFailed", ) // Username Change Counters - val userNameChangeCounter: Counter = meterRegistry.counter("user.name.change.count") + fun incrementUserNameChangeCounter() = meterRegistry.counter("user.name.change.count").increment() // Password Reset Counters - val userPasswordResetRequestCounter: Counter = meterRegistry.counter( - USER_PASSWORD_RESET_COUNT, + fun incrementPasswordResetViaEmailRequestCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, STEP_TAG, "request", MODE_TAG, "email", - ) - val userPasswordResetViaSteamRequestCounter: Counter = meterRegistry.counter( - USER_PASSWORD_RESET_COUNT, + ).increment() + + fun incrementPasswordResetViaEmailSentCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, STEP_TAG, - "request", + "emailSent", MODE_TAG, - "steam", - ) - val userPasswordResetDoneCounter: Counter = meterRegistry.counter( - USER_PASSWORD_RESET_COUNT, + "email", + ).increment() + + fun incrementPasswordResetViaEmailDoneCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, STEP_TAG, "done", MODE_TAG, "email", - ) - val userPasswordResetFailedCounter: Counter = meterRegistry.counter( - USER_PASSWORD_RESET_COUNT, + ).increment() + + fun incrementPasswordResetViaSteamRequestCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, STEP_TAG, - "failed", + "request", MODE_TAG, - "email", - ) - val userPasswordResetDoneViaSteamCounter: Counter = meterRegistry.counter( - USER_PASSWORD_RESET_COUNT, + "steam", + ).increment() + + fun incrementPasswordResetViaSteamDoneCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, STEP_TAG, "done", MODE_TAG, "steam", - ) - val userPasswordResetFailedViaSteamCounter: Counter = meterRegistry.counter( - USER_PASSWORD_RESET_COUNT, + ).increment() + + fun incrementPasswordResetViaEmailFailedCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, + STEP_TAG, + "failed", + MODE_TAG, + "email", + ).increment() + + fun incrementPasswordResetViaSteamFailedCounter() = meterRegistry.counter( + PASSWORD_RESET_COUNT, STEP_TAG, "failed", MODE_TAG, "steam", - ) + ).increment() } diff --git a/src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt b/src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt deleted file mode 100644 index 0ef4ad2f..00000000 --- a/src/main/kotlin/com/faforever/userservice/backend/registration/InvalidRegistrationException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.faforever.userservice.backend.registration - -class InvalidRegistrationException : RuntimeException() diff --git a/src/main/kotlin/com/faforever/userservice/backend/steam/InvalidSteamRedirectException.kt b/src/main/kotlin/com/faforever/userservice/backend/steam/InvalidSteamRedirectException.kt new file mode 100644 index 00000000..f58d0b2d --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/steam/InvalidSteamRedirectException.kt @@ -0,0 +1,3 @@ +package com.faforever.userservice.backend.steam + +class InvalidSteamRedirectException(message: String) : Exception(message) diff --git a/src/main/kotlin/com/faforever/userservice/backend/steam/SteamService.kt b/src/main/kotlin/com/faforever/userservice/backend/steam/SteamService.kt new file mode 100644 index 00000000..a51a1177 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/backend/steam/SteamService.kt @@ -0,0 +1,79 @@ +package com.faforever.userservice.backend.steam + +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.ws.rs.core.UriBuilder +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.net.http.HttpResponse.BodyHandlers + +@ApplicationScoped +class SteamService( + private val fafProperties: FafProperties, + private val userRepository: UserRepository, +) { + companion object { + private val log: Logger = LoggerFactory.getLogger(SteamService::class.java) + const val OPENID_IDENTITY_KEY = "openid.identity" + } + + fun buildLoginUrl(redirectUrl: String): String { + log.debug("Building steam account url for redirect url: {}", redirectUrl) + + return UriBuilder.fromUri(fafProperties.steam().loginUrlFormat()) + .queryParam("openid.ns", "http://specs.openid.net/auth/2.0") + .queryParam("openid.mode", "checkid_setup") + .queryParam("openid.return_to", redirectUrl) + .queryParam("openid.realm", fafProperties.steam().realm()) + .queryParam("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") + .queryParam("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") + .build().toString() + } + + fun parseSteamIdFromRequestParameters(parameters: Map>): String? { + if (!parameters.containsKey(OPENID_IDENTITY_KEY)) { + return null + } + + validateSteamRedirect(parameters) + + log.trace("Parsing steam id from request parameters: {}", parameters) + return parameters[OPENID_IDENTITY_KEY]?.get(0) + ?.let { identityUrl -> identityUrl.substring(identityUrl.lastIndexOf("/") + 1) } + } + + private fun validateSteamRedirect(parameters: Map>) { + log.debug("Checking valid OpenID 2.0 redirect against Steam API, parameters: {}", parameters) + + val uriBuilder = UriBuilder.fromUri(fafProperties.steam().loginUrlFormat()) + parameters.forEach { uriBuilder.queryParam(it.key, it.value.first()) } + uriBuilder.replaceQueryParam("openid.mode", "check_authentication") + + // for some reason the + character doesn't get encoded + log.debug("Verification uri: {}", uriBuilder.build()) + + val client = HttpClient.newHttpClient() + val request = HttpRequest.newBuilder() + .uri(uriBuilder.build()) + .GET() + .build() + + val response: HttpResponse = client.send(request, BodyHandlers.ofString()) + val result = response.body() + + if (result == null || !result.contains("is_valid:true")) { + throw InvalidSteamRedirectException( + "Could not verify steam redirect for identity: ${parameters[OPENID_IDENTITY_KEY]}", + ) + } else { + log.debug("Steam response successfully validated.") + } + } + + fun findUserBySteamId(steamId: String): User? = userRepository.findBySteamId(steamId) +} diff --git a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt index 6a7273cf..a746d711 100644 --- a/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt +++ b/src/main/kotlin/com/faforever/userservice/config/FafProperties.kt @@ -10,6 +10,9 @@ import java.util.* @ConfigMapping(prefix = "faf") interface FafProperties { + @NotBlank + fun selfUrl(): String + fun environment(): Optional /** @@ -32,6 +35,8 @@ interface FafProperties { fun irc(): Irc + fun steam(): Steam + interface Lobby { @NotBlank fun secret(): String @@ -133,4 +138,11 @@ interface FafProperties { fun usernameReservationTimeInMonths(): Long } } + + interface Steam { + fun loginUrlFormat(): String = "https://steamcommunity.com/openid/login" + + @NotBlank + fun realm(): String + } } diff --git a/src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt b/src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt index a94e857d..9a90c41d 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/layout/BackgroundImageLayout.kt @@ -62,5 +62,6 @@ class CardLayout : VerticalLayout(), RouterLayout { init { addClassName("main-card") + width = null } } 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 e516cf5a..1891f9f3 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/layout/CompactLayout.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/layout/CompactLayout.kt @@ -8,6 +8,7 @@ open class CompactVerticalLayout(vararg children: Component) : VerticalLayout(*c init { isPadding = false isSpacing = false + width = null } } 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 index 80fc64d5..5d2d2deb 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/exception/InvalidRegistrationExceptionView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/exception/InvalidRegistrationExceptionView.kt @@ -1,6 +1,6 @@ package com.faforever.userservice.ui.view.exception -import com.faforever.userservice.backend.registration.InvalidRegistrationException +import com.faforever.userservice.backend.account.InvalidRegistrationException import com.faforever.userservice.ui.component.FafLogo import com.faforever.userservice.ui.layout.CardLayout import com.faforever.userservice.ui.layout.CompactVerticalLayout diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt index 5944f5cd..bf0068c0 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt @@ -1,9 +1,9 @@ package com.faforever.userservice.ui.view.oauth2 +import com.faforever.userservice.backend.account.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 @@ -26,7 +26,7 @@ import com.vaadin.flow.router.BeforeEnterObserver import com.vaadin.flow.router.Route import java.time.format.DateTimeFormatter -@Route("/oauth2/login", layout = CardLayout::class) +@Route("/oauth2/account", layout = CardLayout::class) class LoginView( private val hydraService: HydraService, private val vaadinIpService: VaadinIpService, diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverAccountView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverAccountView.kt new file mode 100644 index 00000000..f2cb7be4 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverAccountView.kt @@ -0,0 +1,72 @@ +package com.faforever.userservice.ui.view.registration + +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.HtmlComponent +import com.vaadin.flow.component.UI +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.button.ButtonVariant +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.orderedlayout.FlexComponent +import com.vaadin.flow.component.orderedlayout.FlexLayout +import com.vaadin.flow.component.orderedlayout.HorizontalLayout +import com.vaadin.flow.component.orderedlayout.VerticalLayout +import com.vaadin.flow.router.Route + +@Route("/recover-account", layout = CardLayout::class) +class RecoverAccountView : + CompactVerticalLayout() { + + private val emailSection = VerticalLayout( + Button(getTranslation("recovery.selectMethod.email.link")) { + UI.getCurrent().navigate("/recover-account/email") + }.apply { + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + }, + HtmlComponent("small").apply { + element.text = (getTranslation("recovery.selectMethod.email.description")) + }, + ).apply { + maxWidth = "50%" + } + + private val steamSection = VerticalLayout( + Button(getTranslation("recovery.selectMethod.steam.link")) { + UI.getCurrent().navigate("/recover-account/steam") + }.apply { + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + }, + HtmlComponent("small").apply { + element.text = (getTranslation("recovery.selectMethod.steam.description")) + }, + ).apply { + maxWidth = "50%" + } + + init { + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("recovery.selectMethod.title")) + + val formHeader = HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + setId("form-header") + setWidthFull() + } + + add(formHeader) + add( + FlexLayout(emailSection, steamSection).apply { + flexWrap = FlexLayout.FlexWrap.WRAP + }, + ) + + val footer = VerticalLayout(SocialIcons()).apply { + alignItems = FlexComponent.Alignment.CENTER + } + + add(footer) + } +} diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverSetPasswordView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverSetPasswordView.kt new file mode 100644 index 00000000..00666226 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverSetPasswordView.kt @@ -0,0 +1,140 @@ +package com.faforever.userservice.ui.view.recovery + +import com.faforever.userservice.backend.account.RecoveryService +import com.faforever.userservice.backend.domain.User +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.faforever.userservice.ui.view.registration.ActivateView.PasswordConfirmation +import com.vaadin.flow.component.HtmlComponent +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.Paragraph +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.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("/recover-account/set-password", layout = CardLayout::class) +class RecoverSetPasswordView( + private val recoveryService: RecoveryService, +) : CompactVerticalLayout(), BeforeEnterObserver { + private val usernameInRecovery = HtmlComponent("small") + + 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("recovery.setPassword.submit")) { setPassword() }.apply { + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + setWidthFull() + isEnabled = false + } + + private val binder = Binder(PasswordConfirmation::class.java) + private var user: User? = null + private var recoveryType = RecoveryService.Type.EMAIL + + init { + maxWidth = "30rem" + + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("recovery.setPassword.title")) + val formHeader = + HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + setId("form-header") + setWidthFull() + } + + add(formHeader) + add(Paragraph(usernameInRecovery), password, confirmedPassword) + add(submit) + + val footer = + VerticalLayout(SocialIcons()).apply { + alignItems = FlexComponent.Alignment.CENTER + } + + add(footer) + + binder.forField(password) + .asRequired(getTranslation("register.password.required")) + .withValidator({ it.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 setPassword() { + recoveryService.resetPassword(recoveryType, user?.id!!, password.value) + + Dialog().apply { + add(H2(getTranslation("recovery.setPassword.confirmed.title"))) + add(Span(getTranslation("recovery.setPassword.confirmed.hint"))) + isCloseOnOutsideClick = false + open() + } + } + + override fun beforeEnter(event: BeforeEnterEvent?) { + val parameters = event?.location?.queryParameters?.parameters ?: emptyMap() + + val (recoveryType, user) = try { + recoveryService.parseRecoveryHttpRequest(parameters) + } catch (e: Exception) { + showDialog("recovery.setPassword.failed.title", "recovery.setPassword.invalidToken") + return + } + + if (user == null) { + when (recoveryType) { + RecoveryService.Type.EMAIL -> + showDialog("recovery.setPassword.failed.title", "recovery.setPassword.invalidToken") + RecoveryService.Type.STEAM -> + showDialog("recovery.setPassword.failed.title", "recovery.steam.unknownUser") + } + } else { + usernameInRecovery.element.text = + getTranslation("recovery.setPassword.usernameInRecovery", user.username ?: "") + } + + this.recoveryType = recoveryType + this.user = user + } + + private fun showDialog(titleKey: String, messageKey: String?) { + Dialog().apply { + add(H2(getTranslation(titleKey))) + + if (messageKey != null) { + add(Span(getTranslation(messageKey))) + } + + isCloseOnOutsideClick = false + open() + } + } +} diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverViaEmailView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverViaEmailView.kt new file mode 100644 index 00000000..edc35529 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverViaEmailView.kt @@ -0,0 +1,84 @@ +package com.faforever.userservice.ui.view.recovery + +import com.faforever.userservice.backend.account.RecoveryService +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.HtmlComponent +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.Paragraph +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.value.ValueChangeMode +import com.vaadin.flow.router.Route + +@Route("/recover-account/email", layout = CardLayout::class) +class RecoverViaEmailView( + private val recoveryService: RecoveryService, +) : CompactVerticalLayout() { + + private val usernameOrEmailDescription = + Paragraph( + HtmlComponent("small").apply { + element.setProperty( + "innerHTML", + getTranslation("recovery.email.usernameOrEmailAlternative", "/recover-account"), + ) + }, + ) + + private val usernameOrEmail = + TextField(null, getTranslation("recovery.email.usernameOrEmail")).apply { + setWidthFull() + valueChangeMode = ValueChangeMode.LAZY + } + + private val submit = + Button(getTranslation("recovery.email.submit")) { requestEmail() }.apply { + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + setWidthFull() + } + + init { + maxWidth = "30rem" + + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("recovery.email.title")) + val formHeader = + HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + setId("form-header") + setWidthFull() + } + + add(formHeader) + add(usernameOrEmail, usernameOrEmailDescription) + add(submit) + + val footer = + VerticalLayout(SocialIcons()).apply { + alignItems = FlexComponent.Alignment.CENTER + } + + add(footer) + } + + private fun requestEmail() { + recoveryService.requestPasswordResetViaEmail(usernameOrEmail.value) + + Dialog().apply { + add(H2(getTranslation("recovery.email.sent.title"))) + add(Span(getTranslation("recovery.email.sent.hint"))) + isCloseOnOutsideClick = false + open() + } + } +} diff --git a/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverViaSteamView.kt b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverViaSteamView.kt new file mode 100644 index 00000000..1064f851 --- /dev/null +++ b/src/main/kotlin/com/faforever/userservice/ui/view/recovery/RecoverViaSteamView.kt @@ -0,0 +1,75 @@ +package com.faforever.userservice.ui.view.recovery + +import com.faforever.userservice.backend.account.RecoveryService +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.HtmlComponent +import com.vaadin.flow.component.UI +import com.vaadin.flow.component.button.Button +import com.vaadin.flow.component.button.ButtonVariant +import com.vaadin.flow.component.html.H2 +import com.vaadin.flow.component.html.Image +import com.vaadin.flow.component.html.Paragraph +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.router.Route + +@Route("/recover-account/steam", layout = CardLayout::class) +class RecoverViaSteamView( + private val recoveryService: RecoveryService, +) : CompactVerticalLayout() { + companion object { + const val STEAM_SIGNIN_LOGO_URL = + "https://community.cloudflare.steamstatic.com/public/images/signinthroughsteam/sits_01.png" + } + + private val submit = + Button( + Image(STEAM_SIGNIN_LOGO_URL, "Steam Sign In Logo"), + { redirectToSteam() }, + ).apply { + addThemeVariants(ButtonVariant.LUMO_PRIMARY) + setWidthFull() + style.setPaddingTop("30px") + style.setPaddingBottom("26px") + } + + init { + maxWidth = "30rem" + + val formHeaderLeft = FafLogo() + val formHeaderRight = H2(getTranslation("recovery.steam.title")) + val formHeader = + HorizontalLayout(formHeaderLeft, formHeaderRight).apply { + justifyContentMode = FlexComponent.JustifyContentMode.CENTER + alignItems = FlexComponent.Alignment.CENTER + setId("form-header") + setWidthFull() + } + + add(formHeader) + add( + Paragraph( + HtmlComponent("small").apply { + element.setProperty("innerHTML", getTranslation("recovery.steam.disclaimer")) + }, + ), + ) + add(submit) + + val footer = + VerticalLayout(SocialIcons()).apply { + alignItems = FlexComponent.Alignment.CENTER + } + + add(footer) + } + + private fun redirectToSteam() { + val steamUrl = recoveryService.buildSteamLoginUrl() + UI.getCurrent().page.setLocation(steamUrl) + } +} 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 index 57b525e8..c49d38f6 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/registration/ActivateView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/registration/ActivateView.kt @@ -1,8 +1,8 @@ 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.account.InvalidRegistrationException +import com.faforever.userservice.backend.account.RegisteredUser +import com.faforever.userservice.backend.account.RegistrationService import com.faforever.userservice.backend.security.VaadinIpService import com.faforever.userservice.ui.component.FafLogo import com.faforever.userservice.ui.component.SocialIcons @@ -82,7 +82,7 @@ class ActivateView(private val registrationService: RegistrationService, private binder.forField(password) .asRequired(getTranslation("register.password.required")) - .withValidator({ username -> username.length >= 6 }, getTranslation("register.password.size")) + .withValidator({ it.length >= 6 }, getTranslation("register.password.size")) .bind("password") binder.forField(confirmedPassword) 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 index be4aedcd..a3c2001b 100644 --- a/src/main/kotlin/com/faforever/userservice/ui/view/registration/RegisterView.kt +++ b/src/main/kotlin/com/faforever/userservice/ui/view/registration/RegisterView.kt @@ -1,9 +1,9 @@ package com.faforever.userservice.ui.view.registration +import com.faforever.userservice.backend.account.EmailStatus +import com.faforever.userservice.backend.account.RegistrationService +import com.faforever.userservice.backend.account.UsernameStatus import com.faforever.userservice.backend.recaptcha.RecaptchaService -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.ReCaptcha diff --git a/src/main/kotlin/sh/ory/hydra/model/ConsentRequest.kt b/src/main/kotlin/sh/ory/hydra/model/ConsentRequest.kt index 4dc53456..1247bb73 100644 --- a/src/main/kotlin/sh/ory/hydra/model/ConsentRequest.kt +++ b/src/main/kotlin/sh/ory/hydra/model/ConsentRequest.kt @@ -20,8 +20,8 @@ import io.quarkus.runtime.annotations.RegisterForReflection * @param acr ACR represents the Authentication AuthorizationContext Class Reference value for this authentication session. You can use it to express that, for example, a user authenticated using two factor authentication. * @param client * @param context - * @param loginChallenge LoginChallenge is the login challenge this consent challenge belongs to. It can be used to associate a login and consent request in the login & consent app. - * @param loginSessionId LoginSessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive login requests by a certain user. + * @param loginChallenge LoginChallenge is the account challenge this consent challenge belongs to. It can be used to associate a account and consent request in the account & consent app. + * @param loginSessionId LoginSessionID is the account session ID. If the user-agent reuses a account session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive account requests by a certain user. * @param oidcContext * @param requestUrl RequestURL is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client. It is the URL which initiates the OAuth 2.0 Authorization Code or OAuth 2.0 Implicit flow. This URL is typically not needed, but might come in handy if you want to deal with additional request parameters. * @param requestedAccessTokenAudience @@ -42,10 +42,10 @@ data class ConsentRequest( val client: OAuth2Client? = null, @JsonProperty("context") val context: kotlin.Any? = null, - /* LoginChallenge is the login challenge this consent challenge belongs to. It can be used to associate a login and consent request in the login & consent app. */ + /* LoginChallenge is the account challenge this consent challenge belongs to. It can be used to associate a account and consent request in the account & consent app. */ @JsonProperty("login_challenge") val loginChallenge: kotlin.String? = null, - /* LoginSessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive login requests by a certain user. */ + /* LoginSessionID is the account session ID. If the user-agent reuses a account session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive account requests by a certain user. */ @JsonProperty("login_session_id") val loginSessionId: kotlin.String? = null, @JsonProperty("oidc_context") diff --git a/src/main/kotlin/sh/ory/hydra/model/LoginRequest.kt b/src/main/kotlin/sh/ory/hydra/model/LoginRequest.kt index 1b7ba32e..3e10e0bc 100644 --- a/src/main/kotlin/sh/ory/hydra/model/LoginRequest.kt +++ b/src/main/kotlin/sh/ory/hydra/model/LoginRequest.kt @@ -16,20 +16,20 @@ import io.quarkus.runtime.annotations.RegisterForReflection /** * - * @param challenge ID is the identifier (\"login challenge\") of the login request. It is used to identify the session. + * @param challenge ID is the identifier (\"account challenge\") of the account request. It is used to identify the session. * @param client * @param requestUrl RequestURL is the original OAuth 2.0 Authorization URL requested by the OAuth 2.0 client. It is the URL which initiates the OAuth 2.0 Authorization Code or OAuth 2.0 Implicit flow. This URL is typically not needed, but might come in handy if you want to deal with additional request parameters. * @param requestedAccessTokenAudience * @param requestedScope * @param skip Skip, if true, implies that the client has requested the same scopes from the same user previously. If true, you can skip asking the user to grant the requested scopes, and simply forward the user to the redirect URL. This feature allows you to update / set session information. - * @param subject Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope requested by the OAuth 2.0 client. If this value is set and `skip` is true, you MUST include this subject type when accepting the login request, or the request will fail. + * @param subject Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope requested by the OAuth 2.0 client. If this value is set and `skip` is true, you MUST include this subject type when accepting the account request, or the request will fail. * @param oidcContext - * @param sessionId SessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive login requests by a certain user. + * @param sessionId SessionID is the account session ID. If the user-agent reuses a account session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive account requests by a certain user. */ @RegisterForReflection data class LoginRequest( - /* ID is the identifier (\"login challenge\") of the login request. It is used to identify the session. */ + /* ID is the identifier (\"account challenge\") of the account request. It is used to identify the session. */ @JsonProperty("challenge") val challenge: kotlin.String, @JsonProperty("client") @@ -44,12 +44,12 @@ data class LoginRequest( /* Skip, if true, implies that the client has requested the same scopes from the same user previously. If true, you can skip asking the user to grant the requested scopes, and simply forward the user to the redirect URL. This feature allows you to update / set session information. */ @JsonProperty("skip") val skip: kotlin.Boolean, - /* Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope requested by the OAuth 2.0 client. If this value is set and `skip` is true, you MUST include this subject type when accepting the login request, or the request will fail. */ + /* Subject is the user ID of the end-user that authenticated. Now, that end user needs to grant or deny the scope requested by the OAuth 2.0 client. If this value is set and `skip` is true, you MUST include this subject type when accepting the account request, or the request will fail. */ @JsonProperty("subject") val subject: kotlin.String, @JsonProperty("oidc_context") val oidcContext: OpenIDConnectContext? = null, - /* SessionID is the login session ID. If the user-agent reuses a login session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive login requests by a certain user. */ + /* SessionID is the account session ID. If the user-agent reuses a account session (via cookie / remember flag) this ID will remain the same. If the user-agent did not have an existing authentication session (e.g. remember is false) this will be a new random value. This value is used as the \"sid\" parameter in the ID Token and in OIDC Front-/Back- channel logout. It's value can generally be used to associate consecutive account requests by a certain user. */ @JsonProperty("session_id") val sessionId: kotlin.String? = null, ) diff --git a/src/main/kotlin/sh/ory/hydra/model/OpenIDConnectContext.kt b/src/main/kotlin/sh/ory/hydra/model/OpenIDConnectContext.kt index 2c114856..5cb4e566 100644 --- a/src/main/kotlin/sh/ory/hydra/model/OpenIDConnectContext.kt +++ b/src/main/kotlin/sh/ory/hydra/model/OpenIDConnectContext.kt @@ -17,9 +17,9 @@ import io.quarkus.runtime.annotations.RegisterForReflection /** * * @param acrValues ACRValues is the Authentication AuthorizationContext Class Reference requested in the OAuth 2.0 Authorization request. It is a parameter defined by OpenID Connect and expresses which level of authentication (e.g. 2FA) is required. OpenID Connect defines it as follows: > Requested Authentication AuthorizationContext Class Reference values. Space-separated string that specifies the acr values that the Authorization Server is being requested to use for processing this Authentication Request, with the values appearing in order of preference. The Authentication AuthorizationContext Class satisfied by the authentication performed is returned as the acr Claim Value, as specified in Section 2. The acr Claim is requested as a Voluntary Claim by this parameter. - * @param display Display is a string value that specifies how the Authorization Server displays the authentication and consent user interface pages to the End-User. The defined values are: page: The Authorization Server SHOULD display the authentication and consent UI consistent with a full User Agent page view. If the display parameter is not specified, this is the default display mode. popup: The Authorization Server SHOULD display the authentication and consent UI consistent with a popup User Agent window. The popup User Agent window should be of an appropriate size for a login-focused dialog and should not obscure the entire window that it is popping up over. touch: The Authorization Server SHOULD display the authentication and consent UI consistent with a device that leverages a touch interface. wap: The Authorization Server SHOULD display the authentication and consent UI consistent with a \"feature phone\" type display. The Authorization Server MAY also attempt to detect the capabilities of the User Agent and present an appropriate display. + * @param display Display is a string value that specifies how the Authorization Server displays the authentication and consent user interface pages to the End-User. The defined values are: page: The Authorization Server SHOULD display the authentication and consent UI consistent with a full User Agent page view. If the display parameter is not specified, this is the default display mode. popup: The Authorization Server SHOULD display the authentication and consent UI consistent with a popup User Agent window. The popup User Agent window should be of an appropriate size for a account-focused dialog and should not obscure the entire window that it is popping up over. touch: The Authorization Server SHOULD display the authentication and consent UI consistent with a device that leverages a touch interface. wap: The Authorization Server SHOULD display the authentication and consent UI consistent with a \"feature phone\" type display. The Authorization Server MAY also attempt to detect the capabilities of the User Agent and present an appropriate display. * @param idTokenHintClaims IDTokenHintClaims are the claims of the ID Token previously issued by the Authorization Server being passed as a hint about the End-User's current or past authenticated session with the Client. - * @param loginHint LoginHint hints about the login identifier the End-User might use to log in (if necessary). This hint can be used by an RP if it first asks the End-User for their e-mail address (or other identifier) and then wants to pass that value as a hint to the discovered authorization service. This value MAY also be a phone number in the format specified for the phone_number Claim. The use of this parameter is optional. + * @param loginHint LoginHint hints about the account identifier the End-User might use to log in (if necessary). This hint can be used by an RP if it first asks the End-User for their e-mail address (or other identifier) and then wants to pass that value as a hint to the discovered authorization service. This value MAY also be a phone number in the format specified for the phone_number Claim. The use of this parameter is optional. * @param uiLocales UILocales is the End-User'id preferred languages and scripts for the user interface, represented as a space-separated list of BCP47 [RFC5646] language tag values, ordered by preference. For instance, the value \"fr-CA fr en\" represents a preference for French as spoken in Canada, then French (without a region designation), followed by English (without a region designation). An error SHOULD NOT result if some or all of the requested locales are not supported by the OpenID Provider. */ @@ -28,13 +28,13 @@ data class OpenIDConnectContext( /* ACRValues is the Authentication AuthorizationContext Class Reference requested in the OAuth 2.0 Authorization request. It is a parameter defined by OpenID Connect and expresses which level of authentication (e.g. 2FA) is required. OpenID Connect defines it as follows: > Requested Authentication AuthorizationContext Class Reference values. Space-separated string that specifies the acr values that the Authorization Server is being requested to use for processing this Authentication Request, with the values appearing in order of preference. The Authentication AuthorizationContext Class satisfied by the authentication performed is returned as the acr Claim Value, as specified in Section 2. The acr Claim is requested as a Voluntary Claim by this parameter. */ @JsonProperty("acr_values") val acrValues: kotlin.collections.List? = null, - /* Display is a string value that specifies how the Authorization Server displays the authentication and consent user interface pages to the End-User. The defined values are: page: The Authorization Server SHOULD display the authentication and consent UI consistent with a full User Agent page view. If the display parameter is not specified, this is the default display mode. popup: The Authorization Server SHOULD display the authentication and consent UI consistent with a popup User Agent window. The popup User Agent window should be of an appropriate size for a login-focused dialog and should not obscure the entire window that it is popping up over. touch: The Authorization Server SHOULD display the authentication and consent UI consistent with a device that leverages a touch interface. wap: The Authorization Server SHOULD display the authentication and consent UI consistent with a \"feature phone\" type display. The Authorization Server MAY also attempt to detect the capabilities of the User Agent and present an appropriate display. */ + /* Display is a string value that specifies how the Authorization Server displays the authentication and consent user interface pages to the End-User. The defined values are: page: The Authorization Server SHOULD display the authentication and consent UI consistent with a full User Agent page view. If the display parameter is not specified, this is the default display mode. popup: The Authorization Server SHOULD display the authentication and consent UI consistent with a popup User Agent window. The popup User Agent window should be of an appropriate size for a account-focused dialog and should not obscure the entire window that it is popping up over. touch: The Authorization Server SHOULD display the authentication and consent UI consistent with a device that leverages a touch interface. wap: The Authorization Server SHOULD display the authentication and consent UI consistent with a \"feature phone\" type display. The Authorization Server MAY also attempt to detect the capabilities of the User Agent and present an appropriate display. */ @JsonProperty("display") val display: kotlin.String? = null, /* IDTokenHintClaims are the claims of the ID Token previously issued by the Authorization Server being passed as a hint about the End-User's current or past authenticated session with the Client. */ @JsonProperty("id_token_hint_claims") val idTokenHintClaims: kotlin.Any? = null, - /* LoginHint hints about the login identifier the End-User might use to log in (if necessary). This hint can be used by an RP if it first asks the End-User for their e-mail address (or other identifier) and then wants to pass that value as a hint to the discovered authorization service. This value MAY also be a phone number in the format specified for the phone_number Claim. The use of this parameter is optional. */ + /* LoginHint hints about the account identifier the End-User might use to log in (if necessary). This hint can be used by an RP if it first asks the End-User for their e-mail address (or other identifier) and then wants to pass that value as a hint to the discovered authorization service. This value MAY also be a phone number in the format specified for the phone_number Claim. The use of this parameter is optional. */ @JsonProperty("login_hint") val loginHint: kotlin.String? = null, /* UILocales is the End-User'id preferred languages and scripts for the user interface, represented as a space-separated list of BCP47 [RFC5646] language tag values, ordered by preference. For instance, the value \"fr-CA fr en\" represents a preference for French as spoken in Canada, then French (without a region designation), followed by English (without a region designation). An error SHOULD NOT result if some or all of the requested locales are not supported by the OpenID Provider. */ diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fa33e882..38d72053 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,13 +1,14 @@ faf: + self-url: ${SELF_URL:http://localhost:8080} environment: ${FAF_ENVIRONMENT} real-ip-header: ${REAL_IP_HEADER:X-Real-Ip} 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`} + password-reset-url: ${faf.self-url}/recover-account + register-account-url: ${faf.self-url}/recover-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} + activation-url-format: ${faf.self-url}/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} @@ -16,7 +17,7 @@ faf: 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} + password-reset-url-format: ${faf.self-url}/recover-account/set-password?token=%s subject: ${PASSWORD_RESET_EMAIL_SUBJECT:FAF password reset} mail-template-path: ${PASSWORD_RESET_MAIL_TEMPLATE_PATH:/config/mail/password-reset.html} username: @@ -35,6 +36,8 @@ faf: irc: secret: ${IRC_SECRET:banana} token-ttl: ${IRC_TOKEN_TTL:300} + steam: + realm: ${faf.self-url} mailjet: api-key: ${MAILJET_API_KEY} diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 4bb1f848..e2f56994 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -86,4 +86,25 @@ 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 +register.technicalError=We encountered a technical error during registration. Please restart the registration from the beginning. +recovery.selectMethod.title=Reset password +recovery.selectMethod.email.link=Reset password via Email +recovery.selectMethod.email.description=You need to know your username or email and have access to emails linked to that account. +recovery.selectMethod.steam.link=Reset password via Steam +recovery.selectMethod.steam.description=Your account needs to be linked to Steam already. +recovery.email.title=Reset password via Email +recovery.email.submit=Send recovery email +recovery.email.usernameOrEmail=Username or Email +recovery.email.usernameOrEmailAlternative=In case you know neither the username nor the used email address, try a different recovery method. +recovery.email.sent.title=Recovery email sent +recovery.email.sent.hint=Don't forget to also check your spam folder! Disclaimer: Due to data privacy regulations we cannot tell you if the username or email actually exists in our system and an email was actually sent. +recovery.steam.title=Reset password via Steam +recovery.steam.disclaimer=You will be forwarded to the Steam website to login with your Steam username and password. FAForever can not see your Steam password, nor do we get access to your Steam account in any way. +recovery.steam.unknownUser=There is no user associated with your Steam account. +recovery.setPassword.title=Set new password +recovery.setPassword.usernameInRecovery=You are recovering user ''{0}''. +recovery.setPassword.submit=Set new password +recovery.setPassword.confirmed.title=Your password has been reset +recovery.setPassword.confirmed.hint=For security purposes existing logins on all devices are now invalid. +recovery.setPassword.failed.title=Resetting password failed +recovery.setPassword.invalidToken=There was an error with your reset token. Please make sure you copied the whole url! Otherwise, restart the process and try again. \ No newline at end of file diff --git a/src/test/kotlin/com/faforever/userservice/backend/account/RecoveryServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/account/RecoveryServiceTest.kt new file mode 100644 index 00000000..e4a562c5 --- /dev/null +++ b/src/test/kotlin/com/faforever/userservice/backend/account/RecoveryServiceTest.kt @@ -0,0 +1,273 @@ +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.PASSWORD_RESET +import com.faforever.userservice.backend.steam.SteamService +import com.faforever.userservice.config.FafProperties +import io.quarkus.test.InjectMock +import io.quarkus.test.junit.QuarkusTest +import io.quarkus.test.junit.mockito.InjectSpy +import jakarta.inject.Inject +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsString +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.nullValue +import org.hamcrest.Matchers.startsWith +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import java.time.Duration + +@QuarkusTest +class RecoveryServiceTest { + + @Inject + private lateinit var recoveryService: RecoveryService + + @Inject + private lateinit var fafProperties: FafProperties + + @InjectSpy + private lateinit var loginService: LoginService + + @InjectMock + private lateinit var userRepository: UserRepository + + @InjectMock + private lateinit var fafTokenService: FafTokenService + + @InjectSpy + private lateinit var emailService: EmailService + + @InjectSpy + private lateinit var metricHelper: MetricHelper + + @InjectSpy + private lateinit var steamService: SteamService + + @Test + fun testBuildSteamLoginUrl() { + // Execute + val result = recoveryService.buildSteamLoginUrl() + + // Verify + assertThat(result, startsWith("https://steamcommunity.com/openid/login")) + assertThat(result, containsString("openid.ns")) + assertThat(result, containsString("openid.mode")) + assertThat(result, containsString("openid.return_to")) + assertThat(result, containsString("openid.realm")) + assertThat(result, containsString("openid.identity")) + assertThat(result, containsString("openid.claimed_id")) + } + + @Test + fun testRequestPasswordResetViaEmailWithUnknownUser() { + // Prepare + whenever(userRepository.findByUsernameOrEmail("unknown user")).thenReturn(null) + + // Execute + recoveryService.requestPasswordResetViaEmail("unknown user") + + // Verify + verify(metricHelper).incrementPasswordResetViaEmailRequestCounter() + verify(metricHelper).incrementPasswordResetViaEmailFailedCounter() + + verifyNoInteractions(fafTokenService) + verifyNoInteractions(emailService) + } + + @Test + fun testRequestPasswordResetViaEmailWithKnownUser() { + // Prepare + val testUser = buildTestUser() + whenever(userRepository.findByUsernameOrEmail(testUser.username)) + .thenReturn(testUser) + + // Execute + recoveryService.requestPasswordResetViaEmail(testUser.username) + + // Verify + verify(metricHelper).incrementPasswordResetViaEmailRequestCounter() + verify(metricHelper).incrementPasswordResetViaEmailSentCounter() + + verify(fafTokenService).createToken( + PASSWORD_RESET, + Duration.ofSeconds(fafProperties.account().passwordReset().linkExpirationSeconds()), + attributes = mapOf("id" to testUser.id.toString()), + ) + verify(emailService).sendPasswordResetMail(eq(testUser.username), eq(testUser.email), any()) + } + + @Test + fun testParseRecoveryHttpRequestWithEmptyParameters() { + // Execute + assertThrows { + recoveryService.parseRecoveryHttpRequest(emptyMap()) + } + + // Verify + verify(metricHelper).incrementPasswordResetViaEmailFailedCounter() + } + + @Test + fun testParseRecoveryHttpRequestWithUnknownSteamId() { + // Prepare + val parameters = mapOf("some" to listOf("fake", "values")) + + whenever(steamService.parseSteamIdFromRequestParameters(parameters)) + .thenReturn("someSteamId") + whenever(steamService.findUserBySteamId("someSteamId")) + .thenReturn(null) + + // Execute + val (type, user) = recoveryService.parseRecoveryHttpRequest(parameters) + + // Verify + assertThat(type, equalTo(RecoveryService.Type.STEAM)) + assertThat(user, nullValue()) + + verify(metricHelper).incrementPasswordResetViaSteamFailedCounter() + } + + @Test + fun testParseRecoveryHttpRequestWithKnownSteamId() { + // Prepare + val parameters = mapOf("some" to listOf("fake", "values")) + val testUser = buildTestUser() + + whenever(steamService.parseSteamIdFromRequestParameters(parameters)) + .thenReturn("someSteamId") + whenever(steamService.findUserBySteamId("someSteamId")) + .thenReturn(testUser) + + // Execute + val (type, user) = recoveryService.parseRecoveryHttpRequest(parameters) + + // Verify + assertThat(type, equalTo(RecoveryService.Type.STEAM)) + assertThat(user, equalTo(testUser)) + } + + @Test + fun testParseRecoveryHttpRequestWithInvalidTokenClaims() { + // Prepare + val parameters = mapOf("token" to listOf("tokenValue")) + + whenever(steamService.parseSteamIdFromRequestParameters(parameters)) + .thenReturn(null) + whenever(fafTokenService.getTokenClaims(PASSWORD_RESET, "tokenValue")) + .thenThrow(RuntimeException("invalid token claim")) + + // Execute + assertThrows { + recoveryService.parseRecoveryHttpRequest(parameters) + } + + // Verify + verify(metricHelper).incrementPasswordResetViaEmailFailedCounter() + } + + @Test + fun testParseRecoveryHttpRequestWithMissingUserIdInToken() { + // Prepare + val parameters = mapOf("token" to listOf("tokenValue")) + + whenever(steamService.parseSteamIdFromRequestParameters(parameters)) + .thenReturn(null) + whenever(fafTokenService.getTokenClaims(PASSWORD_RESET, "tokenValue")) + .thenReturn(emptyMap()) + + // Execute + assertThrows { + recoveryService.parseRecoveryHttpRequest(parameters) + } + + // Verify + verify(metricHelper).incrementPasswordResetViaEmailFailedCounter() + } + + @Test + fun testParseRecoveryHttpRequestWithUnknownUserIdInToken() { + // Prepare + val parameters = mapOf("token" to listOf("tokenValue")) + + whenever(steamService.parseSteamIdFromRequestParameters(parameters)) + .thenReturn(null) + whenever(fafTokenService.getTokenClaims(PASSWORD_RESET, "tokenValue")) + .thenReturn(mapOf("id" to "12345")) + whenever(userRepository.findById(12345)).thenReturn(null) + + // Execute + assertThrows { + recoveryService.parseRecoveryHttpRequest(parameters) + } + + // Verify + verify(metricHelper).incrementPasswordResetViaEmailFailedCounter() + } + + @Test + fun testParseRecoveryHttpRequestWithKnownUserIdInToken() { + // Prepare + val parameters = mapOf("token" to listOf("tokenValue")) + val testUser = buildTestUser() + + whenever(steamService.parseSteamIdFromRequestParameters(parameters)) + .thenReturn(null) + whenever(fafTokenService.getTokenClaims(PASSWORD_RESET, "tokenValue")) + .thenReturn(mapOf("id" to "12345")) + whenever(userRepository.findById(12345)).thenReturn(testUser) + + // Execute + val (type, user) = recoveryService.parseRecoveryHttpRequest(parameters) + + // Verify + assertThat(type, equalTo(RecoveryService.Type.EMAIL)) + assertThat(user, equalTo(testUser)) + } + + @Test + fun testResetPasswordEmail() { + // Prepare + val testUser = buildTestUser() + whenever(userRepository.findById(testUser.id!!)).thenReturn(testUser) + + // Execute + recoveryService.resetPassword(RecoveryService.Type.EMAIL, testUser.id!!, "banana") + + // Verify + verify(loginService).resetPassword(testUser.id!!, "banana") + verify(metricHelper).incrementPasswordResetViaEmailDoneCounter() + } + + @Test + fun testResetPasswordSteam() { + // Prepare + val testUser = buildTestUser() + whenever(userRepository.findById(testUser.id!!)).thenReturn(testUser) + + // Execute + recoveryService.resetPassword(RecoveryService.Type.STEAM, testUser.id!!, "banana") + + // Verify + verify(loginService).resetPassword(testUser.id!!, "banana") + verify(metricHelper).incrementPasswordResetViaSteamDoneCounter() + } + + fun buildTestUser() = + User( + id = 1234, + username = "testUser", + password = "testPassword", + email = "test@faforever.com", + ip = null, + ) +} diff --git a/src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/account/RegistrationServiceTest.kt similarity index 98% rename from src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt rename to src/test/kotlin/com/faforever/userservice/backend/account/RegistrationServiceTest.kt index f975c3f8..f62bb200 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/registration/RegistrationServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/account/RegistrationServiceTest.kt @@ -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 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 ce8745cf..ab6b3414 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt @@ -1,8 +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.account.LoginResult +import com.faforever.userservice.backend.account.LoginServiceImpl +import com.faforever.userservice.backend.account.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 29fbf87b..46869811 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.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 io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest diff --git a/src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt b/src/test/kotlin/com/faforever/userservice/backend/security/FafTokenServiceTest.kt similarity index 98% rename from src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt rename to src/test/kotlin/com/faforever/userservice/backend/security/FafTokenServiceTest.kt index 19bf9579..25ff5332 100644 --- a/src/test/kotlin/com/faforever/userservice/backend/security/FAFTokenServiceTest.kt +++ b/src/test/kotlin/com/faforever/userservice/backend/security/FafTokenServiceTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.assertThrows import java.time.Duration @QuarkusTest -class FAFTokenServiceTest { +class FafTokenServiceTest { @Inject private lateinit var fafTokenService: FafTokenService