Skip to content
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
1 change: 0 additions & 1 deletion backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ dependencies {
implementation("tools.jackson.dataformat:jackson-dataformat-csv")
implementation("tools.jackson.module:jackson-module-kotlin")
implementation("com.github.spullara.mustache.java:compiler:0.9.14")
implementation("com.google.auth:google-auth-library-oauth2-http:1.47.0")
implementation("com.google.zxing:core:3.5.4")
implementation("com.google.zxing:javase:3.5.4")
implementation("com.itextpdf:itext-core:9.6.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class ApplicationApiController(

@GetMapping("/app/render-qr")
fun renderQr(response: HttpServletResponse, @RequestParam text: String?, @RequestParam size: Int = 300) {
if (text.isNullOrBlank() || size < 20) {
if (text.isNullOrBlank() || size < 20 || size > 2048) {
response.status = HttpStatus.BAD_REQUEST.value()
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,19 @@ class LoginComponent(

/// -------------------------------------------------------------------------------------------------------------------

val ssoSecurityGroup by SettingGroup(fieldName = "SSO biztonság",
description = "Google és Keycloak belépés biztonsági beállításai")

var rejectUnverifiedEmail by BooleanSettingRef(true, serverSideOnly = true,
fieldName = "Nem megerősített email elutasítása",
description = "Ha be van kapcsolva, a Google/Keycloak belépés elutasításra kerül, ha az email cím nincs megerősítve. Nem jön létre duplikált felhasználó.")

var restrictCrossProviderMerge by BooleanSettingRef(true, serverSideOnly = true,
fieldName = "Provider-közti összevonás tiltása",
description = "Ha be van kapcsolva, egy meglévő fiók csak akkor vonható össze email alapján, ha ugyanaz a bejelentkezési provider hozta létre.")

/// -------------------------------------------------------------------------------------------------------------------

val googleSsoGroup by SettingGroup(fieldName = "Google SSO",
description = "A körtagságok és egyéb körös funkciók ezzel nem működnek automatikusan")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hu.bme.sch.cmsch.component.login

import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.OAuth2Error

class LoginRejectedException(val userMessage: String) :
OAuth2AuthenticationException(OAuth2Error("login_rejected", userMessage, null), userMessage)
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,18 @@ class LoginService(
user = existingByInternalId.get()
log.info("Logging in with existing user ${user.fullName} as google user")
} else {
val existingByEmail = users.findByEmailIgnoreCase(profile.email)
if (loginComponent.rejectUnverifiedEmail && !profile.emailVerified) {
log.warn("Rejecting google login for ${profile.email}: email is not verified")
throw LoginRejectedException("A belépéshez megerősített email cím szükséges.")
}
val existingByEmail =
if (profile.email.isNotBlank()) users.findByEmailIgnoreCase(profile.email) else Optional.empty()
if (existingByEmail.isPresent) {
user = existingByEmail.get()
if (loginComponent.restrictCrossProviderMerge && user.provider != GOOGLE) {
log.warn("Rejecting google login for ${profile.email}: existing account belongs to provider '${user.provider}'")
throw LoginRejectedException("Ehhez az email címhez másik bejelentkezési mód tartozik.")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
log.info("Logging in with existing user ${user.fullName} (found by email) as google user, internalId updated from ${user.internalId} to ${profile.internalId}")
user.internalId = profile.internalId
} else {
Expand Down Expand Up @@ -402,10 +411,18 @@ class LoginService(
user = existingByInternalId.get()
log.info("Logging in with existing user ${user.fullName} as keycloak user")
} else {
if (loginComponent.rejectUnverifiedEmail && !profile.emailVerified) {
log.warn("Rejecting keycloak login for ${profile.email}: email is not verified")
throw LoginRejectedException("A belépéshez megerősített email cím szükséges.")
}
val existingByEmail =
if (!profile.email.isBlank()) users.findByEmailIgnoreCase(profile.email) else Optional.empty()
if (existingByEmail.isPresent) {
user = existingByEmail.get()
if (loginComponent.restrictCrossProviderMerge && user.provider != KEYCLOAK) {
log.warn("Rejecting keycloak login for ${profile.email}: existing account belongs to provider '${user.provider}'")
throw LoginRejectedException("Ehhez az email címhez másik bejelentkezési mód tartozik.")
}
log.info("Logging in with existing user ${user.fullName} (found by email) as keycloak user, internalId updated from ${user.internalId} to ${profile.sid}")
user.internalId = profile.sid
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ data class KeycloakUserInfoResponse(

var email: String = "",

@set:JsonProperty("email_verified")
var emailVerified: Boolean = false,

@set:JsonProperty(required = false)
@get:JsonProperty(required = false)
var groups: List<String> = mutableListOf(),
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,108 +1,155 @@
package hu.bme.sch.cmsch.component.pushnotification

import com.google.auth.oauth2.GoogleCredentials
import hu.bme.sch.cmsch.dto.CmschNotification
import hu.bme.sch.cmsch.model.RoleType
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import hu.bme.sch.cmsch.util.transaction
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Isolation
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.awaitBodilessEntity
import org.springframework.web.reactive.function.client.WebClientResponseException
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import reactor.util.retry.Retry
import tools.jackson.databind.ObjectMapper
import java.time.Duration
import java.util.concurrent.ConcurrentLinkedQueue

private const val MAX_CONCURRENT_SENDS = 100
private const val MAX_RETRIES = 4L
private val RETRY_INITIAL_BACKOFF = Duration.ofMillis(500)
private val RETRY_MAX_BACKOFF = Duration.ofSeconds(5)

@Service
@ConditionalOnBean(PushNotificationComponent::class)
class PushNotificationService(
private val credentials: GoogleCredentials,
private val projectId: String,
private val fcmWebClient: WebClient,
@Value($$"${hu.bme.sch.cmsch.google.service-account-key}") accountKey: String,
webClientBuilder: WebClient.Builder,
objectMapper: ObjectMapper,
private val transactionManager: PlatformTransactionManager,
private val messagingTokenRepository: MessagingTokenRepository,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val fcmWebClient = webClientBuilder.baseUrl("https://fcm.googleapis.com/v1/projects/").build()
private val credentials = PushNotificationServiceCredentials(accountKey, objectMapper)


@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
fun sendToUser(userId: Int, notification: CmschNotification): Int {
log.info("Sending notification {} to user: {}", notification, userId)
val tokens = messagingTokenRepository.findAllTokensByUserId(userId)
val tokens = transactionManager.transaction(readOnly = true) {
messagingTokenRepository.findAllTokensByUserId(userId)
}
return sendNotifications(tokens, notification)
}

@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
fun sendToGroup(groupId: Int, notification: CmschNotification): Int {
log.info("Sending notification {} to group: {}", notification, groupId)
val tokens = messagingTokenRepository.findAllTokensByGroupId(groupId)
val tokens = transactionManager.transaction(readOnly = true) {
messagingTokenRepository.findAllTokensByGroupId(groupId)
}
return sendNotifications(tokens, notification)
}

@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
fun sendToAllUsers(notification: CmschNotification): Int {
log.info("Sending notification {} to all users", notification)
val tokens = messagingTokenRepository.findAllTokens()
val tokens = transactionManager.transaction(readOnly = true) {
messagingTokenRepository.findAllTokens()
}
return sendNotifications(tokens, notification)
}

@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
fun sendToRole(role: RoleType, notification: CmschNotification): Int {
log.info("Sending notification {} to users with role {}", notification, role.displayName)
val tokens = messagingTokenRepository.findAllTokensByRole(role)
val tokens = transactionManager.transaction(readOnly = true) {
messagingTokenRepository.findAllTokensByRole(role)
}
return sendNotifications(tokens, notification)
}

private fun getAccessToken(): String {
credentials.refreshIfExpired()
return credentials.accessToken.tokenValue
return credentials.getAccessToken()
}

private fun sendNotifications(tokens: List<String>, notification: CmschNotification): Int = runBlocking {
if (tokens.isEmpty()) return@runBlocking 0
private fun sendNotifications(tokens: List<String>, notification: CmschNotification): Int {
if (tokens.isEmpty()) return 0

val accessToken = runCatching { getAccessToken() }.getOrElse {
log.error("Failed to get FCM access token", it)
return@runBlocking 0
return 0
}

val results = coroutineScope {
tokens.chunked(100).flatMap { chunk ->
chunk.map { token ->
async {
runCatching {
fcmWebClient.post()
.uri("{projectId}/messages:send", projectId)
.header("Authorization", "Bearer $accessToken")
.bodyValue(notification.toFcmRequest(token))
.retrieve()
.awaitBodilessEntity()
true
}.getOrElse { e ->
val staleTokens = ConcurrentLinkedQueue<String>()

val results = Flux.fromIterable(tokens)
.flatMap({ token ->
fcmWebClient.post()
.uri("{projectId}/messages:send", credentials.projectId)
.header("Authorization", "Bearer $accessToken")
.bodyValue(notification.toFcmRequest(token))
.retrieve()
.toBodilessEntity()
.retryWhen(
Retry.backoff(MAX_RETRIES, RETRY_INITIAL_BACKOFF)
.maxBackoff(RETRY_MAX_BACKOFF)
.filter(::isRetryable)
.doBeforeRetry { signal ->
log.error(
"Retryable error sending to token {} (attempt {}), retrying",
token, signal.totalRetries() + 1, signal.failure()
)
}
)
.thenReturn(SendResult.SUCCESS)
.onErrorResume { e ->
if (e is WebClientResponseException.NotFound) {
staleTokens.add(token)
Mono.just(SendResult.STALE)
} else {
log.error("Failed to send notification to token {}", token, e)
false
Mono.just(SendResult.FAILURE)
}
}
}.awaitAll()
}, MAX_CONCURRENT_SENDS)
.collectList()
.block() ?: emptyList()

if (staleTokens.isNotEmpty()) {
val deleted = transactionManager.transaction(readOnly = false) {
messagingTokenRepository.deleteByTokenIn(staleTokens.toList())
}
log.info("Deleted {} stale messaging tokens", deleted)
}

val successCount = results.count { it }
val successCount = results.count { it == SendResult.SUCCESS }
log.info("Successfully sent notification to {} out of {} devices", successCount, tokens.size)
successCount
return successCount
}

private fun isRetryable(e: Throwable): Boolean = when (e) {
is WebClientResponseException -> e.statusCode.is5xxServerError || e.statusCode.value() == 429
else -> true
}

@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
@Transactional(readOnly = false)
fun addToken(userId: Int, token: String) {
if (messagingTokenRepository.existsByUserIdAndToken(userId, token)) return

log.debug("Inserting messaging token for user: {}, token: {}", userId, token)
messagingTokenRepository.save(MessagingTokenEntity(userId = userId, token = token))
}

@Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE)
@Transactional(readOnly = false)
fun deleteToken(userId: Int, token: String) {
log.debug("Deleting messaging token for user: {}, token: {}", userId, token)
messagingTokenRepository.deleteByUserIdAndToken(userId, token)
}
}

private enum class SendResult {
SUCCESS,
STALE,
FAILURE,
}
Loading
Loading