Skip to content

Commit f129284

Browse files
committed
Add password reset
1 parent e5eb344 commit f129284

37 files changed

+1030
-114
lines changed

.editorconfig

Lines changed: 5 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 7 additions & 1 deletion
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+
}
Lines changed: 3 additions & 0 deletions
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)
Lines changed: 3 additions & 0 deletions
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 renamed to src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt

Lines changed: 17 additions & 4 deletions
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
@@ -56,6 +56,10 @@ interface LoginService {
5656
fun findUserBySubject(subject: String): User?
5757

5858
fun login(usernameOrEmail: String, password: String, ip: IpAddress, requiresGameOwnership: Boolean): LoginResult
59+
60+
fun resetPassword(userId: Int, newPassword: String)
61+
62+
fun revokeSessions()
5963
}
6064

6165
@ApplicationScoped
@@ -96,7 +100,7 @@ class LoginServiceImpl(
96100

97101
if (requiresGameOwnership && !accountLinkRepository.hasOwnershipLink(user.id!!)) {
98102
LOG.debug(
99-
"Lobby login blocked for user '{}' because of missing game ownership verification",
103+
"Lobby account blocked for user '{}' because of missing game ownership verification",
100104
usernameOrEmail,
101105
)
102106
return LoginResult.UserNoGameOwnership
@@ -125,7 +129,7 @@ class LoginServiceImpl(
125129
val accountsAffected = failedAttemptsSummary.accountsAffected
126130
val totalFailedAttempts = failedAttemptsSummary.totalAttempts
127131

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

130134
return if (accountsAffected > securityProperties.failedLoginAccountThreshold() ||
131135
totalFailedAttempts > securityProperties.failedLoginAttemptThreshold()
@@ -138,12 +142,21 @@ class LoginServiceImpl(
138142
LOG.debug("IP '$ip' is trying again to early -> throttle it")
139143
true
140144
} else {
141-
LOG.debug("IP '$ip' triggered a threshold but last login does not hit throttling time")
145+
LOG.debug("IP '$ip' triggered a threshold but last account does not hit throttling time")
142146
false
143147
}
144148
} else {
145149
LOG.trace("IP '$ip' did not hit a throttling limit")
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+
LOG.info("Password for user id {}} has been reset", userId)
161+
}
149162
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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(RegistrationService::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+
val steamId = steamService.parseSteamIdFromRequestParameters(parameters)
66+
67+
return when {
68+
steamId != null -> Type.STEAM to steamService.findUserBySteamId(steamId).also { user ->
69+
if (user == null) metricHelper.incrementPasswordResetViaSteamFailedCounter()
70+
}
71+
72+
token != null -> Type.EMAIL to extractUserFromEmailRecoveryToken(token)
73+
else -> {
74+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
75+
throw InvalidRecoveryException("Could not extract recovery type or user from HTTP request")
76+
}
77+
}
78+
}
79+
80+
private fun extractUserFromEmailRecoveryToken(emailRecoveryToken: String): User {
81+
val claims = try {
82+
fafTokenService.getTokenClaims(FafTokenType.PASSWORD_RESET, emailRecoveryToken)
83+
} catch (exception: Exception) {
84+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
85+
LOG.error("Unable to extract claims", exception)
86+
throw InvalidRecoveryException("Unable to extract claims from token")
87+
}
88+
89+
val userId = claims[KEY_USER_ID]
90+
91+
if (userId.isNullOrBlank()) {
92+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
93+
throw InvalidRecoveryException("No user id found in token claims")
94+
}
95+
96+
val user = userRepository.findById(userId.toInt())
97+
98+
if (user == null) {
99+
metricHelper.incrementPasswordResetViaEmailFailedCounter()
100+
throw InvalidRecoveryException("User with id $userId not found")
101+
}
102+
103+
return user
104+
}
105+
106+
fun resetPassword(type: Type, userId: Int, newPassword: String) {
107+
loginService.resetPassword(userId, newPassword)
108+
109+
when (type) {
110+
Type.EMAIL -> metricHelper.incrementPasswordResetViaEmailDoneCounter()
111+
Type.STEAM -> metricHelper.incrementPasswordResetViaSteamDoneCounter()
112+
}
113+
}
114+
}

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

Lines changed: 5 additions & 24 deletions
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

Lines changed: 15 additions & 3 deletions
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 account.*
87+
FROM account
88+
INNER JOIN service_links ON account.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

0 commit comments

Comments
 (0)