Skip to content

Commit ea0113b

Browse files
committed
Add password reset
1 parent e5eb344 commit ea0113b

33 files changed

+1041
-95
lines changed

.editorconfig

+5
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ max_line_length=120
66
end_of_line=lf
77
ij_any_line_comment_add_space = true
88
ij_any_line_comment_at_first_column = false
9+
10+
# Disable wildcard imports entirely
11+
ij_kotlin_name_count_to_use_star_import = 2147483647
12+
ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
13+
ij_kotlin_packages_to_use_import_on_demand = unset

frontend/themes/faforever/styles.css

+1-1
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ header {
8080

8181
.main-card {
8282
border-radius: 2px;
83-
max-width: 30rem;
83+
max-width: max(80%, 30rem);
8484
margin: 3rem auto;
8585
background-color: rgba(220, 220, 220, 0.8);
8686
box-shadow: 3px 4px 5px 5px rgba(0, 0, 0, 0.2);

src/main/bundles/dev.bundle

551 KB
Binary file not shown.

src/main/kotlin/com/faforever/userservice/AppConfig.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@ package com.faforever.userservice
22

33
import com.vaadin.flow.component.page.AppShellConfigurator
44
import com.vaadin.flow.theme.Theme
5+
import io.quarkus.runtime.StartupEvent
56
import jakarta.enterprise.context.ApplicationScoped
7+
import jakarta.enterprise.event.Observes
68

79
@Theme("faforever")
810
@ApplicationScoped
9-
class AppConfig : AppShellConfigurator
11+
class AppConfig : AppShellConfigurator {
12+
fun onStart(@Observes event: StartupEvent) {
13+
System.setProperty("vaadin.copilot.enable", "false")
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.faforever.userservice.backend.account
2+
3+
class InvalidRecoveryException(message: String) : Exception(message)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.faforever.userservice.backend.account
2+
3+
class InvalidRegistrationException : Exception()

src/main/kotlin/com/faforever/userservice/backend/login/LoginService.kt src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.faforever.userservice.backend.login
1+
package com.faforever.userservice.backend.account
22

33
import com.faforever.userservice.backend.domain.AccountLinkRepository
44
import com.faforever.userservice.backend.domain.Ban
@@ -9,6 +9,7 @@ import com.faforever.userservice.backend.domain.LoginLog
99
import com.faforever.userservice.backend.domain.LoginLogRepository
1010
import com.faforever.userservice.backend.domain.User
1111
import com.faforever.userservice.backend.domain.UserRepository
12+
import com.faforever.userservice.backend.hydra.HydraService
1213
import com.faforever.userservice.backend.security.PasswordEncoder
1314
import io.smallrye.config.ConfigMapping
1415
import jakarta.enterprise.context.ApplicationScoped
@@ -56,6 +57,8 @@ interface LoginService {
5657
fun findUserBySubject(subject: String): User?
5758

5859
fun login(usernameOrEmail: String, password: String, ip: IpAddress, requiresGameOwnership: Boolean): LoginResult
60+
61+
fun resetPassword(userId: Int, newPassword: String)
5962
}
6063

6164
@ApplicationScoped
@@ -66,6 +69,7 @@ class LoginServiceImpl(
6669
private val accountLinkRepository: AccountLinkRepository,
6770
private val passwordEncoder: PasswordEncoder,
6871
private val banRepository: BanRepository,
72+
private val hydraService: HydraService,
6973
) : LoginService {
7074
companion object {
7175
private val LOG: Logger = LoggerFactory.getLogger(LoginServiceImpl::class.java)
@@ -146,4 +150,15 @@ class LoginServiceImpl(
146150
false
147151
}
148152
}
153+
154+
override fun resetPassword(userId: Int, newPassword: String) {
155+
userRepository.findById(userId)!!.apply {
156+
password = passwordEncoder.encode(newPassword)
157+
userRepository.persist(this)
158+
}
159+
160+
hydraService.revokeConsentRequest(userId.toString())
161+
162+
LOG.info("Password for user id {}} has been reset", userId)
163+
}
149164
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package com.faforever.userservice.backend.account
2+
3+
import com.faforever.userservice.backend.domain.User
4+
import com.faforever.userservice.backend.domain.UserRepository
5+
import com.faforever.userservice.backend.email.EmailService
6+
import com.faforever.userservice.backend.metrics.MetricHelper
7+
import com.faforever.userservice.backend.security.FafTokenService
8+
import com.faforever.userservice.backend.security.FafTokenType
9+
import com.faforever.userservice.backend.steam.SteamService
10+
import com.faforever.userservice.config.FafProperties
11+
import jakarta.enterprise.context.ApplicationScoped
12+
import org.slf4j.Logger
13+
import org.slf4j.LoggerFactory
14+
import java.time.Duration
15+
16+
@ApplicationScoped
17+
class RecoveryService(
18+
private val fafProperties: FafProperties,
19+
private val metricHelper: MetricHelper,
20+
private val userRepository: UserRepository,
21+
private val fafTokenService: FafTokenService,
22+
private val steamService: SteamService,
23+
private val emailService: EmailService,
24+
private val loginService: LoginService,
25+
) {
26+
enum class Type {
27+
EMAIL,
28+
STEAM,
29+
}
30+
31+
companion object {
32+
private val LOG: Logger = LoggerFactory.getLogger(RecoveryService::class.java)
33+
private const val KEY_USER_ID = "id"
34+
}
35+
36+
fun requestPasswordResetViaEmail(usernameOrEmail: String) {
37+
metricHelper.incrementPasswordResetViaEmailRequestCounter()
38+
val user = userRepository.findByUsernameOrEmail(usernameOrEmail)
39+
40+
if (user == null) {
41+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
42+
LOG.info("No user found for recovery with username/email: {}", usernameOrEmail)
43+
} else {
44+
val token = fafTokenService.createToken(
45+
fafTokenType = FafTokenType.PASSWORD_RESET,
46+
lifetime = Duration.ofSeconds(fafProperties.account().passwordReset().linkExpirationSeconds()),
47+
attributes = mapOf(KEY_USER_ID to user.id.toString()),
48+
)
49+
val passwordResetUrl = fafProperties.account().passwordReset().passwordResetUrlFormat().format(token)
50+
emailService.sendPasswordResetMail(user.username, user.email, passwordResetUrl)
51+
metricHelper.incrementPasswordResetViaEmailSentCounter()
52+
}
53+
}
54+
55+
fun buildSteamLoginUrl() =
56+
steamService.buildLoginUrl(
57+
redirectUrl =
58+
fafProperties.account().passwordReset().passwordResetUrlFormat().format("STEAM"),
59+
)
60+
61+
fun parseRecoveryHttpRequest(parameters: Map<String, List<String>>): Pair<Type, User?> {
62+
// At first glance it may seem strange that a service is parsing http request parameters,
63+
// but the parameters of the request are determined by this service itself in the request reset phase!
64+
val token = parameters["token"]?.first()
65+
LOG.debug("Extracted token: {}", token)
66+
67+
val steamId = steamService.parseSteamIdFromRequestParameters(parameters)
68+
LOG.debug("Extracted Steam id: {}", steamId)
69+
70+
return when {
71+
steamId != null -> Type.STEAM to steamService.findUserBySteamId(steamId).also { user ->
72+
if (user == null) metricHelper.incrementPasswordResetViaSteamFailedCounter()
73+
}
74+
75+
token != null -> Type.EMAIL to extractUserFromEmailRecoveryToken(token)
76+
else -> {
77+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
78+
throw InvalidRecoveryException("Could not extract recovery type or user from HTTP request")
79+
}
80+
}
81+
}
82+
83+
private fun extractUserFromEmailRecoveryToken(emailRecoveryToken: String): User {
84+
val claims = try {
85+
fafTokenService.getTokenClaims(FafTokenType.PASSWORD_RESET, emailRecoveryToken)
86+
} catch (exception: Exception) {
87+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
88+
LOG.error("Unable to extract claims", exception)
89+
throw InvalidRecoveryException("Unable to extract claims from token")
90+
}
91+
92+
val userId = claims[KEY_USER_ID]
93+
94+
if (userId.isNullOrBlank()) {
95+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
96+
throw InvalidRecoveryException("No user id found in token claims")
97+
}
98+
99+
val user = userRepository.findById(userId.toInt())
100+
101+
if (user == null) {
102+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
103+
throw InvalidRecoveryException("User with id $userId not found")
104+
}
105+
106+
return user
107+
}
108+
109+
fun resetPassword(type: Type, userId: Int, newPassword: String) {
110+
loginService.resetPassword(userId, newPassword)
111+
112+
when (type) {
113+
Type.EMAIL -> metricHelper.incrementPasswordResetViaEmailDoneCounter()
114+
Type.STEAM -> metricHelper.incrementPasswordResetViaSteamDoneCounter()
115+
}
116+
}
117+
}

src/main/kotlin/com/faforever/userservice/backend/registration/RegistrationService.kt src/main/kotlin/com/faforever/userservice/backend/account/RegistrationService.kt

+5-24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.faforever.userservice.backend.registration
1+
package com.faforever.userservice.backend.account
22

33
import com.faforever.userservice.backend.domain.DomainBlacklistRepository
44
import com.faforever.userservice.backend.domain.IpAddress
@@ -46,14 +46,13 @@ class RegistrationService(
4646
private val LOG: Logger = LoggerFactory.getLogger(RegistrationService::class.java)
4747
private const val KEY_USERNAME = "username"
4848
private const val KEY_EMAIL = "email"
49-
private const val KEY_USER_ID = "id"
5049
}
5150

5251
fun register(username: String, email: String) {
5352
checkUsernameAndEmail(username, email)
5453

5554
sendActivationEmail(username, email)
56-
metricHelper.userRegistrationCounter.increment()
55+
metricHelper.incrementUserRegistrationCounter()
5756
}
5857

5958
private fun sendActivationEmail(username: String, email: String) {
@@ -69,23 +68,6 @@ class RegistrationService(
6968
emailService.sendActivationMail(username, email, activationUrl)
7069
}
7170

72-
fun resetPassword(user: User) {
73-
sendPasswordResetEmail(user)
74-
metricHelper.userPasswordResetRequestCounter.increment()
75-
}
76-
77-
private fun sendPasswordResetEmail(user: User) {
78-
val token = fafTokenService.createToken(
79-
FafTokenType.REGISTRATION,
80-
Duration.ofSeconds(fafProperties.account().passwordReset().linkExpirationSeconds()),
81-
mapOf(
82-
KEY_USER_ID to user.id.toString(),
83-
),
84-
)
85-
val passwordResetUrl = fafProperties.account().passwordReset().passwordResetUrlFormat().format(token)
86-
emailService.sendPasswordResetMail(user.username, user.email, passwordResetUrl)
87-
}
88-
8971
@Transactional
9072
fun usernameAvailable(username: String): UsernameStatus {
9173
val exists = userRepository.existsByUsername(username)
@@ -113,9 +95,8 @@ class RegistrationService(
11395
}
11496

11597
fun validateRegistrationToken(registrationToken: String): RegisteredUser {
116-
val claims: Map<String, String>
117-
try {
118-
claims = fafTokenService.getTokenClaims(FafTokenType.REGISTRATION, registrationToken)
98+
val claims = try {
99+
fafTokenService.getTokenClaims(FafTokenType.REGISTRATION, registrationToken)
119100
} catch (exception: Exception) {
120101
LOG.error("Unable to extract claims", exception)
121102
throw InvalidRegistrationException()
@@ -146,7 +127,7 @@ class RegistrationService(
146127
userRepository.persist(user)
147128

148129
LOG.info("User has been activated: {}", user)
149-
metricHelper.userActivationCounter.increment()
130+
metricHelper.incrementUserActivationCounter()
150131

151132
emailService.sendWelcomeToFafMail(username, email)
152133

src/main/kotlin/com/faforever/userservice/backend/domain/User.kt

+15-3
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ data class User(
1818
@GeneratedValue(strategy = GenerationType.IDENTITY)
1919
val id: Int? = null,
2020
@Column(name = "login")
21-
val username: String,
22-
val password: String,
23-
val email: String,
21+
var username: String,
22+
var password: String,
23+
var email: String,
2424
val ip: String?,
2525
) : PanacheEntityBase {
2626

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

8181
fun existsByEmail(email: String): Boolean = count("email = ?1", email) > 0
82+
83+
fun findBySteamId(steamId: String): User? =
84+
getEntityManager().createNativeQuery(
85+
"""
86+
SELECT login.*
87+
FROM login
88+
INNER JOIN service_links ON login.id = service_links.user_id
89+
WHERE type = 'STEAM' and service_id = :steamId
90+
""".trimIndent(),
91+
User::class.java,
92+
).setParameter("steamId", steamId)
93+
.resultList.firstOrNull() as User?
8294
}
8395

8496
@ApplicationScoped

src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt

+22-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package com.faforever.userservice.backend.hydra
22

3+
import com.faforever.userservice.backend.account.LoginResult
4+
import com.faforever.userservice.backend.account.LoginService
35
import com.faforever.userservice.backend.domain.IpAddress
46
import com.faforever.userservice.backend.domain.UserRepository
5-
import com.faforever.userservice.backend.login.LoginResult
6-
import com.faforever.userservice.backend.login.LoginService
77
import com.faforever.userservice.backend.security.OAuthScope
88
import jakarta.enterprise.context.ApplicationScoped
99
import jakarta.enterprise.inject.Produces
1010
import jakarta.transaction.Transactional
1111
import org.eclipse.microprofile.rest.client.inject.RestClient
12+
import org.slf4j.Logger
13+
import org.slf4j.LoggerFactory
1214
import sh.ory.hydra.model.AcceptConsentRequest
1315
import sh.ory.hydra.model.AcceptLoginRequest
1416
import sh.ory.hydra.model.ConsentRequest
@@ -48,6 +50,8 @@ class HydraService(
4850
private val userRepository: UserRepository,
4951
) {
5052
companion object {
53+
private val LOG: Logger = LoggerFactory.getLogger(HydraService::class.java)
54+
5155
private const val HYDRA_ERROR_USER_BANNED = "user_banned"
5256
private const val HYDRA_ERROR_NO_OWNERSHIP_VERIFICATION = "ownership_not_verified"
5357
private const val HYDRA_ERROR_TECHNICAL_ERROR = "technical_error"
@@ -171,4 +175,20 @@ class HydraService(
171175
val redirectResponse = hydraClient.rejectConsentRequest(challenge, GenericError("scope_denied"))
172176
return RedirectTo(redirectResponse.redirectTo)
173177
}
178+
179+
fun revokeConsentRequest(subject: String, client: String? = null) {
180+
LOG.info(
181+
"Revoking consent sessions for subject `{}` on client `{}`",
182+
subject,
183+
client ?: "all",
184+
)
185+
val response = hydraClient.revokeRefreshTokens(
186+
subject = subject,
187+
all = client == null,
188+
client = client,
189+
)
190+
if (response.status != 204) {
191+
LOG.error("Revoking tokens from Hydra failed for request (subject={}, client={})", subject, client)
192+
}
193+
}
174194
}

0 commit comments

Comments
 (0)