Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: terms of service check on login #368

Merged
merged 7 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Build
on: [push]
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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.security.PasswordEncoder
import com.faforever.userservice.backend.tos.TosService
import com.faforever.userservice.config.FafProperties
import jakarta.enterprise.context.ApplicationScoped
import jakarta.transaction.Transactional
Expand Down Expand Up @@ -43,6 +44,7 @@ class RegistrationService(
private val fafProperties: FafProperties,
private val emailService: EmailService,
private val metricHelper: MetricHelper,
private val tosService: TosService,
) {
companion object {
private val LOG: Logger = LoggerFactory.getLogger(RegistrationService::class.java)
Expand Down Expand Up @@ -138,6 +140,7 @@ class RegistrationService(
password = encodedPassword,
email = email,
ip = ipAddress.value,
acceptedTos = tosService.findLatestTos()?.version,
)

userRepository.persist(user)
Expand Down
18 changes: 18 additions & 0 deletions src/main/kotlin/com/faforever/userservice/backend/domain/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ data class User(
var password: String,
var email: String,
val ip: String?,
@Column(name = "accepted_tos")
var acceptedTos: Short?,
) : PanacheEntityBase {

override fun toString(): String =
Expand Down Expand Up @@ -59,6 +61,17 @@ data class Permission(
val updateTime: LocalDateTime,
) : PanacheEntityBase

@Entity(name = "terms_of_service")
data class TermsOfService(
@Id
@GeneratedValue
val version: Short,
@Column(name = "valid_from", nullable = false)
val validFrom: LocalDateTime,
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
val content: String,
) : PanacheEntityBase

@ApplicationScoped
class UserRepository : PanacheRepositoryBase<User, Int> {
fun findByUsernameOrEmail(usernameOrEmail: String): User? =
Expand Down Expand Up @@ -101,3 +114,8 @@ class AccountLinkRepository : PanacheRepositoryBase<AccountLink, String> {
fun hasOwnershipLink(userId: Int): Boolean =
count("userId = ?1 and ownership", userId) > 0
}

@ApplicationScoped
class TermsOfServiceRepository : PanacheRepositoryBase<TermsOfService, Short> {
fun findLatest(): TermsOfService? = find("validFrom <= ?1 order by version desc", LocalDateTime.now()).firstResult()
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ sealed interface LoginResponse {

data class RejectedLogin(val unrecoverableLoginFailure: LoginResult.UnrecoverableLoginFailure) : LoginResponse
data class FailedLogin(val recoverableLoginFailure: LoginResult.RecoverableLoginFailure) : LoginResponse
data class SuccessfulLogin(val redirectTo: RedirectTo) : LoginResponse
data class SuccessfulLogin(val redirectTo: RedirectTo, val userId: String) : LoginResponse
}

@JvmInline
Expand Down Expand Up @@ -109,11 +109,12 @@ class HydraService(
}

is LoginResult.SuccessfulLogin -> {
val userId = loginResult.userId.toString()
val redirectResponse = hydraClient.acceptLoginRequest(
challenge,
AcceptLoginRequest(subject = loginResult.userId.toString()),
AcceptLoginRequest(subject = userId),
)
LoginResponse.SuccessfulLogin(RedirectTo(redirectResponse.redirectTo))
LoginResponse.SuccessfulLogin(RedirectTo(redirectResponse.redirectTo), userId)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.faforever.userservice.backend.tos

import com.faforever.userservice.backend.domain.TermsOfService
import com.faforever.userservice.backend.domain.TermsOfServiceRepository
import com.faforever.userservice.backend.domain.UserRepository
import jakarta.enterprise.context.ApplicationScoped
import jakarta.transaction.Transactional

@ApplicationScoped
class TosService(
private val userRepository: UserRepository,
private val tosRepository: TermsOfServiceRepository,
) {
fun hasUserAcceptedLatestTos(userId: Int): Boolean {
val user = userRepository.findById(userId)
?: throw IllegalStateException("User id $userId not found")

val latestTosVersion = tosRepository.findLatest()?.version
return latestTosVersion == user.acceptedTos
}

fun findLatestTos(): TermsOfService? = tosRepository.findLatest()

@Transactional
fun acceptLatestTos(userId: Int) {
val user = userRepository.findById(userId)
?: throw IllegalStateException("User id $userId not found")

val latestTos = tosRepository.findLatest()
user.acceptedTos = latestTos?.version
userRepository.persist(user)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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.security.VaadinIpService
import com.faforever.userservice.backend.tos.TosService
import com.faforever.userservice.config.FafProperties
import com.faforever.userservice.ui.component.FontAwesomeIcon
import com.faforever.userservice.ui.component.LogoHeader
Expand All @@ -14,7 +15,9 @@ import com.faforever.userservice.ui.layout.CompactVerticalLayout
import com.vaadin.flow.component.Key
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.Anchor
import com.vaadin.flow.component.html.IFrame
import com.vaadin.flow.component.html.Span
import com.vaadin.flow.component.orderedlayout.FlexComponent
import com.vaadin.flow.component.orderedlayout.HorizontalLayout
Expand All @@ -24,13 +27,15 @@ import com.vaadin.flow.component.textfield.TextField
import com.vaadin.flow.router.BeforeEnterEvent
import com.vaadin.flow.router.BeforeEnterObserver
import com.vaadin.flow.router.Route
import java.net.URI
import java.time.format.DateTimeFormatter

@Route("/oauth2/login", layout = CardLayout::class)
class LoginView(
private val hydraService: HydraService,
private val vaadinIpService: VaadinIpService,
private val fafProperties: FafProperties,
private val tosService: TosService,
) :
CompactVerticalLayout(), BeforeEnterObserver {

Expand Down Expand Up @@ -98,10 +103,66 @@ class LoginView(
when (val loginResponse = hydraService.login(challenge, usernameOrEmail.value, password.value, ipAddress)) {
is LoginResponse.FailedLogin -> displayErrorMessage(loginResponse.recoverableLoginFailure)
is LoginResponse.RejectedLogin -> displayRejectedMessage(loginResponse.unrecoverableLoginFailure)
is LoginResponse.SuccessfulLogin -> ui.ifPresent { it.page.setLocation(loginResponse.redirectTo.uri) }
is LoginResponse.SuccessfulLogin -> onSuccessfulLogin(loginResponse)
}
}

private fun onSuccessfulLogin(loginResponse: LoginResponse.SuccessfulLogin) {
val userId = loginResponse.userId.toInt()
val hasUserAcceptedLatestTos = tosService.hasUserAcceptedLatestTos(userId)
val redirectUri = loginResponse.redirectTo.uri

if (!hasUserAcceptedLatestTos) {
showTosConsentModal(redirectUri, userId)
} else {
redirectToUrl(redirectUri)
}
}

private fun showTosConsentModal(redirectUri: URI, userId: Int) {
val modalDialog = Dialog().apply {
headerTitle = getTranslation("login.tos.dialogTitle")
width = "70%"
height = "90%"
isCloseOnEsc = false
isCloseOnOutsideClick = false
}

val iframe = IFrame(fafProperties.account().registration().termsOfServiceUrl()).apply {
width = "100%"
height = "90%"
}

val acceptButton = Button(getTranslation("login.tos.dialogAcceptBtn")) {
modalDialog.close()
onTosAccept(userId, redirectUri)
}.apply { addThemeVariants(ButtonVariant.LUMO_PRIMARY) }

val declineButton = Button(getTranslation("login.tos.dialogDeclineBtn")) { onTosDecline() }
val buttonLayout = HorizontalLayout(acceptButton, declineButton).apply {
justifyContentMode = FlexComponent.JustifyContentMode.END
}

modalDialog.add(iframe, buttonLayout)
modalDialog.open()
}

private fun onTosAccept(userId: Int, redirectUri: URI) {
tosService.acceptLatestTos(userId)
redirectToUrl(redirectUri)
}

private fun onTosDecline() {
Dialog().apply {
add(Span(getTranslation("login.tos.notAcceptedMsg")))
open()
}
}

private fun redirectToUrl(redirectUri: URI) {
ui.ifPresent { it.page.setLocation(redirectUri) }
}

private fun displayErrorMessage(loginError: LoginResult.RecoverableLoginFailure) {
errorMessage.text = when (loginError) {
is LoginResult.RecoverableLoginOrCredentialsMismatch -> getTranslation("login.badCredentials")
Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ login.forgotPassword=Forgot Password
login.registerAccount=Register Account
login.technicalError=We encountered a technical error during login. This mostly occurs if you reloaded a page or sent a request twice. Please restart the login from the beginning.
login.invalidFlow=Invalid login flow
login.tos.dialogTitle=Please accept our latest terms of services
login.tos.dialogAcceptBtn=Accept
login.tos.dialogDeclineBtn=Decline
login.tos.notAcceptedMsg=Login is not possible until you accept our latest terms of services
consent.appRequest=This app requests the following permissions of your FAF account:
consent.termsOfService=Terms of Service
consent.privacyStatement=Privacy Statement
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,5 +265,6 @@ class RecoveryServiceTest {
password = "testPassword",
email = "[email protected]",
ip = null,
acceptedTos = 1,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import com.faforever.userservice.backend.domain.NameRecordRepository
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.security.FafTokenService
import com.faforever.userservice.backend.tos.TosService
import com.faforever.userservice.config.FafProperties
import io.quarkus.mailer.MockMailbox
import io.quarkus.test.InjectMock
Expand All @@ -30,12 +30,12 @@ import org.mockito.kotlin.whenever
class RegistrationServiceTest {

companion object {
private const val username = "someUsername"
private const val email = "[email protected]"
private const val password = "somePassword"
private const val USERNAME = "someUsername"
private const val EMAIL = "[email protected]"
private const val PASSWORD = "somePassword"
private val ipAddress = IpAddress("127.0.0.1")

private val user = User(1, username, password, email, null)
private val user = User(1, USERNAME, PASSWORD, EMAIL, null, null)
}

@InjectSpy
Expand All @@ -60,7 +60,7 @@ class RegistrationServiceTest {
private lateinit var domainBlacklistRepository: DomainBlacklistRepository

@InjectMock
private lateinit var fafTokenService: FafTokenService
private lateinit var tosService: TosService

@BeforeEach
fun setup() {
Expand All @@ -69,9 +69,9 @@ class RegistrationServiceTest {

@Test
fun registerSuccess() {
registrationService.register(username, email)
registrationService.register(USERNAME, EMAIL)

val sent = mailbox.getMailsSentTo(email)
val sent = mailbox.getMailsSentTo(EMAIL)
assertThat(sent, hasSize(1))
val actual = sent[0]
assertThat(actual.subject, `is`(fafProperties.account().registration().subject()))
Expand All @@ -81,41 +81,42 @@ class RegistrationServiceTest {
fun registerUsernameTaken() {
whenever(userRepository.existsByUsername(anyString())).thenReturn(true)

assertThrows<IllegalArgumentException> { registrationService.register(username, email) }
assertThrows<IllegalArgumentException> { registrationService.register(USERNAME, EMAIL) }
}

@Test
fun registerUsernameReserved() {
whenever(nameRecordRepository.existsByPreviousNameAndChangeTimeAfter(anyString(), any())).thenReturn(true)

assertThrows<IllegalArgumentException> { registrationService.register(username, email) }
assertThrows<IllegalArgumentException> { registrationService.register(USERNAME, EMAIL) }
}

@Test
fun registerEmailTaken() {
whenever(userRepository.findByEmail(anyString())).thenReturn(user)
val newTestUsername = "newUsername"

registrationService.register(newTestUsername, email)
registrationService.register(newTestUsername, EMAIL)

val expectedLink = fafProperties.account().passwordReset().passwordResetInitiateEmailUrlFormat().format(email)
verify(emailService, times(1)).sendEmailAlreadyTakenMail(newTestUsername, username, email, expectedLink)
val expectedLink = fafProperties.account().passwordReset().passwordResetInitiateEmailUrlFormat().format(EMAIL)
verify(emailService, times(1)).sendEmailAlreadyTakenMail(newTestUsername, USERNAME, EMAIL, expectedLink)
}

@Test
fun registerEmailBlacklisted() {
whenever(domainBlacklistRepository.existsByDomain(anyString())).thenReturn(true)

assertThrows<IllegalStateException> { registrationService.register(username, email) }
assertThrows<IllegalStateException> { registrationService.register(USERNAME, EMAIL) }
}

@Test
fun activateSuccess() {
registrationService.activate(RegisteredUser(username, email), ipAddress, password)
registrationService.activate(RegisteredUser(USERNAME, EMAIL), ipAddress, PASSWORD)

verify(userRepository).persist(any<User>())
verify(tosService).findLatestTos()

val sent = mailbox.getMailsSentTo(email)
val sent = mailbox.getMailsSentTo(EMAIL)
assertThat(sent, hasSize(1))
val actual = sent[0]
assertEquals(fafProperties.account().registration().welcomeSubject(), actual.subject)
Expand All @@ -126,7 +127,7 @@ class RegistrationServiceTest {
whenever(userRepository.existsByUsername(anyString())).thenReturn(true)

assertThrows<IllegalArgumentException> {
registrationService.activate(RegisteredUser(username, email), ipAddress, password)
registrationService.activate(RegisteredUser(USERNAME, EMAIL), ipAddress, PASSWORD)
}
}

Expand All @@ -135,7 +136,7 @@ class RegistrationServiceTest {
whenever(nameRecordRepository.existsByPreviousNameAndChangeTimeAfter(anyString(), any())).thenReturn(true)

assertThrows<IllegalArgumentException> {
registrationService.activate(RegisteredUser(username, email), ipAddress, password)
registrationService.activate(RegisteredUser(USERNAME, EMAIL), ipAddress, PASSWORD)
}
}

Expand All @@ -144,7 +145,7 @@ class RegistrationServiceTest {
whenever(userRepository.findByEmail(anyString())).thenReturn(user)

assertThrows<IllegalArgumentException> {
registrationService.activate(RegisteredUser(username, email), ipAddress, password)
registrationService.activate(RegisteredUser(USERNAME, EMAIL), ipAddress, PASSWORD)
}
}

Expand All @@ -153,7 +154,7 @@ class RegistrationServiceTest {
whenever(domainBlacklistRepository.existsByDomain(anyString())).thenReturn(true)

assertThrows<IllegalArgumentException> {
registrationService.activate(RegisteredUser(username, email), ipAddress, password)
registrationService.activate(RegisteredUser(USERNAME, EMAIL), ipAddress, PASSWORD)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class LoginServiceTest {
private const val PASSWORD = "somePassword"
private val IP_ADDRESS = IpAddress("127.0.0.1")

private val USER = User(1, USERNAME, PASSWORD, EMAIL, null)
private val USER = User(1, USERNAME, PASSWORD, EMAIL, null, null)
}

@Inject
Expand Down
Loading
Loading