Skip to content
Open
1 change: 0 additions & 1 deletion frontend/themes/faforever/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ input {
}

.error {
text-align: center;
background-color: white;
border: 1px solid var(--lumo-primary-color);
font-weight: bold;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ sealed interface LoginResult {
sealed interface RecoverableLoginFailure : LoginResult
object ThrottlingActive : RecoverableLoginFailure
object RecoverableLoginOrCredentialsMismatch : RecoverableLoginFailure
data class MissedBan(
val reason: String,
val startTime: OffsetDateTime,
val endTime: OffsetDateTime,
) : RecoverableLoginFailure

sealed interface UnrecoverableLoginFailure : LoginResult
object TechnicalError : UnrecoverableLoginFailure
Expand Down Expand Up @@ -90,15 +95,25 @@ class LoginServiceImpl(
return LoginResult.RecoverableLoginOrCredentialsMismatch
}

val lastLogin = loginLogRepository.findLastLoginTime(user.id)
logLogin(usernameOrEmail, user, ip)

val activeGlobalBan = findActiveGlobalBan(user)

if (activeGlobalBan != null) {
LOG.debug("User '{}' is banned by {}", usernameOrEmail, activeGlobalBan)
return LoginResult.UserBanned(activeGlobalBan.reason, activeGlobalBan.expiresAt)
}

val missedGlobalBan = findMissedGlobalBan(user, lastLogin)
if (missedGlobalBan != null) {
LOG.debug("User '{}' missed a ban {} and needs to be informed about it", usernameOrEmail, missedGlobalBan)
return LoginResult.MissedBan(
missedGlobalBan.reason,
missedGlobalBan.createTime,
missedGlobalBan.expiresAt!!,
)
}

if (requiresGameOwnership && !accountLinkRepository.hasOwnershipLink(user.id!!)) {
LOG.debug(
"Lobby login blocked for user '{}' because of missing game ownership verification",
Expand All @@ -118,9 +133,23 @@ class LoginServiceImpl(
loginLogRepository.persist(LoginLog(0, null, unknownLogin.take(100), ip.value, false))

private fun findActiveGlobalBan(user: User): Ban? =
banRepository.findGlobalBansByPlayerId(user.id!!)
banRepository.findGlobalBansByPlayerId(user.id)
.firstOrNull { it.isActive }

private fun findMissedGlobalBan(user: User, lastLogin: LocalDateTime?): Ban? {
val lastRelevantBan = banRepository.findGlobalBansByPlayerId(user.id)
.firstOrNull {
it.revokeTime == null && it.expiresAt != null &&
it.expiresAt!!.isAfter(OffsetDateTime.now().minusDays(90))
} ?: return null

return if (lastLogin == null || lastLogin.isBefore(lastRelevantBan.createTime.toLocalDateTime())) {
lastRelevantBan
} else {
null
}
}

private fun throttlingRequired(ip: IpAddress): Boolean {
val failedAttemptsSummary = loginLogRepository.findFailedAttemptsByIpAfterDate(
ip.value,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ data class Ban(
val revokeReason: String?,
@Column(name = "revoke_author_id")
val revokeAuthorId: Long?,
@Column(name = "create_time")
val createTime: OffsetDateTime,
) : PanacheEntityBase {

val isActive: Boolean
Expand All @@ -51,6 +53,8 @@ data class Ban(

@ApplicationScoped
class BanRepository : PanacheRepository<Ban> {
fun findGlobalBansByPlayerId(playerId: Int) =
find("playerId = ?1 and level = BanLevel.GLOBAL", playerId).list()
fun findGlobalBansByPlayerId(playerId: Int?): List<Ban> {
if (playerId == null) return listOf()
return find("playerId = ?1 and level = BanLevel.GLOBAL", playerId).list()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ data class FailedAttemptsSummary(

@ApplicationScoped
class LoginLogRepository : PanacheRepository<LoginLog> {
fun findLastLoginTime(userId: Int?): LocalDateTime? {
if (userId == null) {
return null
}
return find("userId = ?1 order by createTime desc", userId).firstResult()?.createTime
}

fun findFailedAttemptsByIpAfterDate(ip: String, date: LocalDateTime): FailedAttemptsSummary? =
getEntityManager().createQuery(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class HydraService(
return when (val loginResult = loginService.login(usernameOrEmail, password, ip, requiresGameOwnership)) {
is LoginResult.ThrottlingActive -> LoginResponse.FailedLogin(loginResult)
is LoginResult.RecoverableLoginOrCredentialsMismatch -> LoginResponse.FailedLogin(loginResult)
is LoginResult.MissedBan -> LoginResponse.FailedLogin(loginResult)
is LoginResult.UserNoGameOwnership -> {
rejectLoginRequest(
challenge,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ 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.Key
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.dialog.Dialog
Expand All @@ -29,6 +30,8 @@ import com.vaadin.flow.router.BeforeEnterObserver
import com.vaadin.flow.router.Route
import java.net.URI
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle

@Route("/oauth2/login", layout = CardLayout::class)
class LoginView(
Expand Down Expand Up @@ -65,9 +68,16 @@ class LoginView(
setWidthFull()
addClassName("error")
add(FontAwesomeIcon().apply { addClassNames("fas fa-exclamation-triangle") })
errorMessage.style.set("white-space", "pre-line")
add(errorMessage)
}

private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatterBuilder()
.append(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
.appendLiteral(" ")
.append(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
.toFormatter(UI.getCurrent().locale)

private val usernameOrEmail = TextField(null, getTranslation("login.usernameOrEmail")).apply {
setWidthFull()
}
Expand Down Expand Up @@ -167,6 +177,14 @@ class LoginView(
errorMessage.text = when (loginError) {
is LoginResult.RecoverableLoginOrCredentialsMismatch -> getTranslation("login.badCredentials")
is LoginResult.ThrottlingActive -> getTranslation("login.throttled")
is LoginResult.MissedBan -> {
val startTime = loginError.startTime.format(dateTimeFormatter)
val endTime = loginError.startTime.format(dateTimeFormatter)
val intro = getTranslation("ban.missed.intro", startTime, endTime)
val reason = "${getTranslation("ban.reason")} ${loginError.reason}"
val explanation = getTranslation("ban.missed")
"$intro\n$reason\n$explanation"
}
}
errorLayout.isVisible = true
}
Expand All @@ -190,7 +208,7 @@ class LoginView(

is LoginResult.UserBanned -> {
header.setTitle(getTranslation("ban.title"))
val expiration = loginError.expiresAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) ?: getTranslation(
val expiration = loginError.expiresAt?.format(dateTimeFormatter) ?: getTranslation(
"ban.permanent",
)
val expirationText = "${getTranslation("ban.expiration")} $expiration."
Expand Down
2 changes: 2 additions & 0 deletions src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ ban.title=You are banned from FAForever
ban.expiration=Ban expires:
ban.permanent=Never
ban.reason=Reason:
ban.missed.intro=You have been banned from FAForever from {0} to {1}.
ban.missed=The ban has expired in the meantime. Please log in again.
error.internal=An internal error has occurred
register.title=Registration
register.action=Create Account
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,19 @@ class LoginServiceTest {
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)
whenever(banRepository.findGlobalBansByPlayerId(anyInt())).thenReturn(
listOf(
Ban(1, 1, 100, BanLevel.GLOBAL, "test", OffsetDateTime.MAX, null, null, null, null),
Ban(
1,
1,
100,
BanLevel.GLOBAL,
"test",
OffsetDateTime.MAX,
null,
null,
null,
null,
OffsetDateTime.now(),
),
),
)

Expand All @@ -99,7 +111,19 @@ class LoginServiceTest {
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)
whenever(banRepository.findGlobalBansByPlayerId(anyInt())).thenReturn(
listOf(
Ban(1, 1, 100, BanLevel.GLOBAL, "test", null, null, null, null, null),
Ban(
1,
1,
100,
BanLevel.GLOBAL,
"test",
null,
null,
null,
null,
null,
OffsetDateTime.now(),
),
),
)

Expand Down Expand Up @@ -150,6 +174,7 @@ class LoginServiceTest {
null,
null,
null,
OffsetDateTime.now(),
),
),
)
Expand All @@ -170,15 +195,44 @@ class LoginServiceTest {
100,
BanLevel.GLOBAL,
"test",
OffsetDateTime.MIN,
OffsetDateTime.now().minusHours(1),
null,
null,
null,
null,
OffsetDateTime.now().minusDays(1),
),
),
)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(loginLogRepository.findLastLoginTime(anyInt())).thenReturn(LocalDateTime.now().minusDays(2))
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)

val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
assertThat(result, instanceOf(LoginResult.MissedBan::class.java))
}

@Test
fun loginWithPreviouslyBannedUserAfterAcknowledgement() {
whenever(banRepository.findGlobalBansByPlayerId(anyInt())).thenReturn(
listOf(
Ban(
1,
1,
100,
BanLevel.GLOBAL,
"test",
OffsetDateTime.now().minusHours(1),
null,
null,
null,
null,
OffsetDateTime.now().minusDays(1),
),
),
)
whenever(userRepository.findByUsernameOrEmail(anyString())).thenReturn(USER)
whenever(loginLogRepository.findLastLoginTime(anyInt())).thenReturn(LocalDateTime.now())
whenever(passwordEncoder.matches(anyString(), anyString())).thenReturn(true)

val result = loginService.login(USERNAME, PASSWORD, IP_ADDRESS, false)
Expand Down
Loading