diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt index f835b99d41..8f218e7ec2 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/batch/V2ExportController.kt @@ -2,6 +2,7 @@ package io.tolgee.api.v2.controllers.batch import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import io.tolgee.component.ProjectLastModifiedManager import io.tolgee.constants.Message import io.tolgee.dtos.request.export.ExportParams import io.tolgee.exceptions.BadRequestException @@ -28,6 +29,7 @@ import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.io.InputStream import java.io.OutputStream @@ -46,30 +48,79 @@ class V2ExportController( private val languageService: LanguageService, private val authenticationFacade: AuthenticationFacade, private val streamingResponseBodyProvider: StreamingResponseBodyProvider, + private val projectLastModifiedManager: ProjectLastModifiedManager, ) { @GetMapping(value = [""]) - @Operation(summary = "Export data") + @Operation( + summary = "Export data", + description = """ + Exports project data in various formats (JSON, properties, YAML, etc.). + + ## HTTP Conditional Requests Support + + This endpoint supports HTTP conditional requests using both If-Modified-Since and If-None-Match headers: + + - **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date + - **If-None-Match header provided**: The server checks if the project data has changed by comparing the eTag value + - **Data not modified**: Returns HTTP 304 Not Modified with empty body + - **Data modified or no header**: Returns HTTP 200 OK with the exported data, Last-Modified header, and ETag header + + The Last-Modified header in the response contains the timestamp of the last project modification, + and the ETag header contains a unique identifier for the current project state. Both can be used + for subsequent conditional requests to avoid unnecessary data transfer when the project hasn't changed. + + Cache-Control header is set to max-age=0 to ensure validation on each request. + """, + ) @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess @ExportApiResponse fun exportData( @ParameterObject params: ExportParams, - ): ResponseEntity { - params.languages = - languageService - .getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id) - .toList() - .map { language -> language.tag } - .toSet() - val exported = exportService.export(projectHolder.project.id, params) - checkExportNotEmpty(exported) - return getExportResponse(params, exported) + request: WebRequest, + ): ResponseEntity? { + return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder -> + params.languages = + languageService + .getLanguagesForExport(params.languages, projectHolder.project.id, authenticationFacade.authenticatedUser.id) + .toList() + .map { language -> language.tag } + .toSet() + val exported = exportService.export(projectHolder.project.id, params) + checkExportNotEmpty(exported) + val preparedResponse = getExportResponse(params, exported) + headersBuilder.headers(preparedResponse.headers) + preparedResponse.body + } } @PostMapping(value = [""]) @Operation( summary = "Export data (post)", - description = """Exports data (post). Useful when exceeding allowed URL size.""", + description = """ + Exports project data in various formats (JSON, properties, YAML, etc.). + Useful when exceeding allowed URL size with GET requests. + + ## HTTP Conditional Requests Support + + This endpoint supports HTTP conditional requests using both If-Modified-Since and If-None-Match headers: + + - **If-Modified-Since header provided**: The server checks if the project data has been modified since the specified date + - **If-None-Match header provided**: The server checks if the project data has changed by comparing the eTag value + - **Data not modified**: Returns HTTP 304 Not Modified with empty body + - **Data modified or no header**: Returns HTTP 200 OK with the exported data, Last-Modified header, and ETag header + + Note: This endpoint uses a custom implementation that returns 304 Not Modified for all HTTP methods + (including POST) when conditional headers indicate the data hasn't changed. This differs from Spring's + default behavior which returns 412 for POST requests, but is appropriate here since POST is used only + to accommodate large request parameters, not to modify data. + + The Last-Modified header in the response contains the timestamp of the last project modification, + and the ETag header contains a unique identifier for the current project state. Both can be used + for subsequent conditional requests to avoid unnecessary data transfer when the project hasn't changed. + + Cache-Control header is set to max-age=0 to ensure validation on each request. + """, ) @ReadOnlyOperation @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @@ -77,8 +128,9 @@ class V2ExportController( @ExportApiResponse fun exportPost( @RequestBody params: ExportParams, - ): ResponseEntity { - return exportData(params) + request: WebRequest, + ): ResponseEntity? { + return exportData(params, request) } private fun getZipHeaders(projectName: String): HttpHeaders { @@ -104,7 +156,7 @@ class V2ExportController( private fun getExportResponse( params: ExportParams, exported: Map, - ): ResponseEntity { + ): PreparedResponse { if (exported.entries.size == 1 && !params.zip) { return exportSingleFile(exported, params) } @@ -120,30 +172,39 @@ class V2ExportController( private fun exportSingleFile( exported: Map, params: ExportParams, - ): ResponseEntity { + ): PreparedResponse { val (fileName, stream) = exported.entries.first() val fileNameWithoutSlash = fileName.replace("^/(.*)".toRegex(), "$1") val headers = getHeaders(fileNameWithoutSlash, params.format.mediaType) - return ResponseEntity.ok().headers(headers).body( - streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> - IOUtils.copy(stream, out) - stream.close() - out.close() - }, + return PreparedResponse( + headers = headers, + body = + streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> + IOUtils.copy(stream, out) + stream.close() + out.close() + }, ) } - private fun getZipResponseEntity(exported: Map): ResponseEntity { + private fun getZipResponseEntity(exported: Map): PreparedResponse { val httpHeaders = getZipHeaders(projectHolder.project.name) - return ResponseEntity.ok().headers(httpHeaders).body( - streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> - streamZipResponse(out, exported) - }, + return PreparedResponse( + headers = httpHeaders, + body = + streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> + streamZipResponse(out, exported) + }, ) } + data class PreparedResponse( + val headers: HttpHeaders, + val body: StreamingResponseBody?, + ) + private fun streamZipResponse( out: OutputStream, exported: Map, diff --git a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt index fafe59c291..d45ede84f3 100644 --- a/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/translation/TranslationsController.kt @@ -15,7 +15,7 @@ import io.tolgee.activity.ActivityService import io.tolgee.activity.RequestActivity import io.tolgee.activity.data.ActivityType import io.tolgee.api.v2.controllers.IController -import io.tolgee.component.ProjectTranslationLastModifiedManager +import io.tolgee.component.ProjectLastModifiedManager import io.tolgee.constants.Message import io.tolgee.dtos.queryResults.TranslationHistoryView import io.tolgee.dtos.request.translation.GetTranslationsParams @@ -57,7 +57,6 @@ import org.springframework.data.domain.Sort import org.springframework.data.web.PagedResourcesAssembler import org.springframework.data.web.SortDefault import org.springframework.hateoas.PagedModel -import org.springframework.http.CacheControl import org.springframework.http.ResponseEntity import org.springframework.transaction.annotation.Transactional import org.springframework.web.bind.WebDataBinder @@ -73,7 +72,6 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.context.request.WebRequest -import java.util.concurrent.TimeUnit @Suppress("MVCPathVariableInspection", "SpringJavaInjectionPointsAutowiringInspection") @RestController @@ -102,10 +100,10 @@ class TranslationsController( private val authenticationFacade: AuthenticationFacade, private val screenshotService: ScreenshotService, private val activityService: ActivityService, - private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager, private val createOrUpdateTranslationsFacade: CreateOrUpdateTranslationsFacade, private val taskService: ITaskService, private val translationSuggestionService: TranslationSuggestionService, + private val projectLastModifiedManager: ProjectLastModifiedManager, ) : IController { @GetMapping(value = ["/{languages}"]) @Operation( @@ -162,17 +160,11 @@ When null, resulting file will be a flat key-value object. filterTag: List? = null, request: WebRequest, ): ResponseEntity>? { - val lastModified: Long = projectTranslationLastModifiedManager.getLastModified(projectHolder.project.id) + return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { + val permittedTags = + securityService + .filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages) - if (request.checkNotModified(lastModified)) { - return null - } - - val permittedTags = - securityService - .filterViewPermissionByTag(projectId = projectHolder.project.id, languageTags = languages) - - val response = translationService.getTranslations( languageTags = permittedTags, namespace = ns, @@ -180,14 +172,7 @@ When null, resulting file will be a flat key-value object. structureDelimiter = request.getStructureDelimiter(), filterTag = filterTag, ) - - return ResponseEntity - .ok() - .lastModified(lastModified) - .cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)) - .body( - response, - ) + } } @PutMapping("") diff --git a/backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt b/backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt new file mode 100644 index 0000000000..7157ecd3ed --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/component/ProjectLastModifiedManager.kt @@ -0,0 +1,126 @@ +package io.tolgee.component + +import io.tolgee.security.ProjectHolder +import org.springframework.http.CacheControl +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Component +import org.springframework.web.context.request.WebRequest +import java.util.concurrent.TimeUnit + +/** + * Component responsible for managing HTTP conditional requests based on project data modifications. + * + * This manager implements HTTP conditional request mechanisms (If-Modified-Since/Last-Modified headers + * and If-None-Match/ETag headers) to enable efficient caching of project-related data. It helps reduce + * unnecessary data transfer and processing by allowing clients to cache responses and only receive new + * data when the project has actually been modified. + */ +@Component +class ProjectLastModifiedManager( + private val projectTranslationLastModifiedManager: ProjectTranslationLastModifiedManager, + private val projectHolder: ProjectHolder, +) { + /** + * Executes a function only when the project data has been modified since the client's last request. + * + * This method implements HTTP conditional request handling by: + * 1. Retrieving the last modification timestamp and eTag of the current project + * 2. Checking if the client's If-Modified-Since or If-None-Match headers indicate the data is still current + * 3. If data hasn't changed, returning HTTP 304 Not Modified response with appropriate headers + * 4. If data has changed, executing the provided function and wrapping the result in a ResponseEntity + * with appropriate cache control headers + * + * The response includes: + * - Last-Modified header set to the project's modification timestamp + * - ETag header set to the project's unique identifier + * - Cache-Control header set to max-age=0 to ensure validation on each request + * + */ + fun onlyWhenProjectDataChanged( + request: WebRequest, + fn: ( + /** + * Enables setting of additional headers on the response. + */ + headersBuilder: ResponseEntity.HeadersBuilder<*>, + ) -> T?, + ): ResponseEntity? { + val (lastModified, eTag) = projectTranslationLastModifiedManager.getLastModifiedInfo(projectHolder.project.id) + + // Custom conditional request logic that works for all HTTP methods (not just GET/HEAD) + if (isNotModified(request, eTag, lastModified)) { + // Return 304 Not Modified response with proper headers for all HTTP methods + return ResponseEntity + .status(304) + .lastModified(lastModified) + .eTag(eTag) + .cacheControl(DEFAULT_CACHE_CONTROL_HEADER) + .build() + } + + val headersBuilder = + ResponseEntity + .ok() + .lastModified(lastModified) + .eTag(eTag) + .cacheControl(DEFAULT_CACHE_CONTROL_HEADER) + + val response = fn(headersBuilder) + + return headersBuilder + .body( + response, + ) + } + + /** + * Custom implementation of conditional request checking that works for all HTTP methods. + * + * Unlike Spring's checkNotModified which only returns 304 for GET/HEAD methods, + * this implementation returns true (indicating not modified) for any HTTP method + * when the conditional headers indicate the client already has the current version. + * + * @param request The web request containing conditional headers + * @param eTag The current ETag value for the resource + * @param lastModified The last modification timestamp in milliseconds + * @return true if the resource has not been modified, false otherwise + */ + private fun isNotModified( + request: WebRequest, + eTag: String, + lastModified: Long, + ): Boolean { + // Check If-None-Match header (ETag-based conditional) + val ifNoneMatch = request.getHeader("If-None-Match") + if (ifNoneMatch != null) { + // If the ETag matches, the resource hasn't been modified + return ifNoneMatch == eTag || ifNoneMatch == "\"$eTag\"" + } + + // Check If-Modified-Since header (timestamp-based conditional) + val ifModifiedSince = request.getHeader("If-Modified-Since") + if (ifModifiedSince != null) { + try { + val ifModifiedSinceDate = + java.time.Instant + .from( + java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME + .parse(ifModifiedSince), + ).toEpochMilli() + // If the resource wasn't modified after the client's timestamp, it hasn't been modified + // There is the 1s precision, so we need to trim the milliseconds + return lastModified / 1000 <= ifModifiedSinceDate / 1000 + } catch (e: Exception) { + // If we can't parse the date, assume it has been modified + return false + } + } + + // No conditional headers present, assume modified + return false + } + + companion object { + val DEFAULT_CACHE_CONTROL_HEADER = CacheControl.maxAge(0, TimeUnit.SECONDS) + } +} diff --git a/backend/api/src/main/kotlin/io/tolgee/component/ProjectTranslationLastModifiedManager.kt b/backend/api/src/main/kotlin/io/tolgee/component/ProjectTranslationLastModifiedManager.kt index b710d41be1..54cd066f27 100644 --- a/backend/api/src/main/kotlin/io/tolgee/component/ProjectTranslationLastModifiedManager.kt +++ b/backend/api/src/main/kotlin/io/tolgee/component/ProjectTranslationLastModifiedManager.kt @@ -6,16 +6,25 @@ import org.springframework.cache.Cache import org.springframework.cache.CacheManager import org.springframework.context.event.EventListener import org.springframework.stereotype.Component +import java.util.UUID @Component class ProjectTranslationLastModifiedManager( val currentDateProvider: CurrentDateProvider, val cacheManager: CacheManager, ) { - fun getLastModified(projectId: Long): Long { - return getCache()?.get(projectId)?.get() as? Long + /** + * Returns the last modification information for a given project. + * If no information exists in the cache, creates new modification info with the current timestamp and UUID, + * stores it in the cache and returns it. + * + * @param projectId The ID of the project to get last modified info for + * @return LastModifiedInfo containing timestamp and ETag for the project + */ + fun getLastModifiedInfo(projectId: Long): LastModifiedInfo { + return getCache()?.get(projectId)?.get() as? LastModifiedInfo ?: let { - val now = currentDateProvider.date.time + val now = getCurrentInfo() getCache()?.put(projectId, now) now } @@ -26,7 +35,19 @@ class ProjectTranslationLastModifiedManager( @EventListener fun onActivity(event: OnProjectActivityEvent) { event.activityRevision.projectId?.let { projectId -> - getCache()?.put(projectId, currentDateProvider.date.time) + getCache()?.put(projectId, getCurrentInfo()) } } + + private fun getCurrentInfo(): LastModifiedInfo { + return LastModifiedInfo( + lastModified = currentDateProvider.date.time, + eTag = UUID.randomUUID().toString(), + ) + } + + data class LastModifiedInfo( + val lastModified: Long, + val eTag: String, + ) } diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt index a13e087827..d4d7246872 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/ExportController.kt @@ -1,9 +1,10 @@ package io.tolgee.controllers -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.databind.ObjectMapper import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import io.tolgee.api.v2.controllers.IController +import io.tolgee.component.ProjectLastModifiedManager import io.tolgee.model.enums.Scope import io.tolgee.security.ProjectHolder import io.tolgee.security.authentication.AllowApiAccess @@ -16,9 +17,9 @@ import org.apache.tomcat.util.http.fileupload.IOUtils import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController +import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody import java.io.ByteArrayInputStream import java.io.OutputStream @@ -40,46 +41,47 @@ class ExportController( private val projectHolder: ProjectHolder, private val authenticationFacade: AuthenticationFacade, private val streamingResponseBodyProvider: StreamingResponseBodyProvider, + private val projectLastModifiedManager: ProjectLastModifiedManager, + private val objectMapper: ObjectMapper, ) : IController { + @Suppress("MVCPathVariableInspection") @GetMapping(value = ["/jsonZip"], produces = ["application/zip"]) @Operation(summary = "Export to ZIP of jsons", description = "Exports data as ZIP of jsons", deprecated = true) @RequiresProjectPermissions([Scope.TRANSLATIONS_VIEW]) @AllowApiAccess @Deprecated("Use v2 export controller") - fun doExportJsonZip( - @PathVariable("projectId") projectId: Long?, - ): ResponseEntity { - val allLanguages = - permissionService.getPermittedViewLanguages( - projectHolder.project.id, - authenticationFacade.authenticatedUser.id, - ) + fun doExportJsonZip(request: WebRequest): ResponseEntity? { + return projectLastModifiedManager.onlyWhenProjectDataChanged(request) { headersBuilder -> + val allLanguages = + permissionService.getPermittedViewLanguages( + projectHolder.project.id, + authenticationFacade.authenticatedUser.id, + ) - return ResponseEntity - .ok() - .header( + headersBuilder.header( "Content-Disposition", - String.format("attachment; filename=\"%s.zip\"", projectHolder.project.name), - ).body( - streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> - val zipOutputStream = ZipOutputStream(out) - val translations = - translationService.getTranslations( - allLanguages.map { it.tag }.toSet(), - null, - projectHolder.project.id, - '.', - ) - for ((key, value) in translations) { - zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key))) - val data = jacksonObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsBytes(value) - val byteArrayInputStream = ByteArrayInputStream(data) - IOUtils.copy(byteArrayInputStream, zipOutputStream) - byteArrayInputStream.close() - zipOutputStream.closeEntry() - } - zipOutputStream.close() - }, + "attachment; filename=\"${projectHolder.project.name}.zip\"", ) + + streamingResponseBodyProvider.createStreamingResponseBody { out: OutputStream -> + val zipOutputStream = ZipOutputStream(out) + val translations = + translationService.getTranslations( + allLanguages.map { it.tag }.toSet(), + null, + projectHolder.project.id, + '.', + ) + for ((key, value) in translations) { + zipOutputStream.putNextEntry(ZipEntry(String.format("%s.json", key))) + val data = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(value) + val byteArrayInputStream = ByteArrayInputStream(data) + IOUtils.copy(byteArrayInputStream, zipOutputStream) + byteArrayInputStream.close() + zipOutputStream.closeEntry() + } + zipOutputStream.close() + } + } } } diff --git a/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt b/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt index 39a1b9d663..23687467cc 100644 --- a/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt +++ b/backend/api/src/main/kotlin/io/tolgee/websocket/WebSocketConfig.kt @@ -1,10 +1,11 @@ package io.tolgee.websocket -import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.dtos.cacheable.ApiKeyDto +import io.tolgee.exceptions.PermissionException import io.tolgee.model.enums.Scope -import io.tolgee.security.authentication.JwtService import io.tolgee.security.authentication.TolgeeAuthentication import io.tolgee.service.security.SecurityService +import io.tolgee.util.logger import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Lazy import org.springframework.messaging.Message @@ -23,10 +24,10 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo @Configuration @EnableWebSocketMessageBroker class WebSocketConfig( - @Lazy - private val jwtService: JwtService, @Lazy private val securityService: SecurityService, + @Lazy + private val websocketAuthenticationResolver: WebsocketAuthenticationResolver, ) : WebSocketMessageBrokerConfigurer { override fun configureMessageBroker(config: MessageBrokerRegistry) { config.enableSimpleBroker("/") @@ -46,15 +47,14 @@ class WebSocketConfig( val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java) if (accessor?.command == StompCommand.CONNECT) { - val tokenString = accessor.getNativeHeader("jwtToken")?.firstOrNull() - accessor.user = if (tokenString == null) null else jwtService.validateToken(tokenString) + accessor.user = websocketAuthenticationResolver.resolve(accessor) } - val user = (accessor?.user as? TolgeeAuthentication)?.principal + val authentication = accessor?.user as? TolgeeAuthentication if (accessor?.command == StompCommand.SUBSCRIBE) { - checkProjectPathPermissions(user, accessor.destination) - checkUserPathPermissions(user, accessor.destination) + checkProjectPathPermissionsAuth(authentication, accessor.destination) + checkUserPathPermissionsAuth(authentication, accessor.destination) } return message @@ -63,8 +63,8 @@ class WebSocketConfig( ) } - fun checkProjectPathPermissions( - user: UserAccountDto?, + fun checkProjectPathPermissionsAuth( + authentication: TolgeeAuthentication?, destination: String?, ) { val projectId = @@ -77,19 +77,30 @@ class WebSocketConfig( ?.toLong() } ?: return - if (user == null) { + if (authentication == null) { throw MessagingException("Unauthenticated") } + val apiKey = authentication.credentials as? ApiKeyDto + val user = authentication.principal + try { - securityService.checkProjectPermissionNoApiKey(projectId = projectId, Scope.KEYS_VIEW, user) - } catch (e: Exception) { - throw MessagingException("Forbidden") + securityService.checkProjectPermission( + projectId = projectId, + requiredPermission = Scope.KEYS_VIEW, + user = user, + apiKey = apiKey, + ) + } catch (e: PermissionException) { + logger().debug("User / API key does not have required scopes", e) + throwForbidden() } + + return } - fun checkUserPathPermissions( - user: UserAccountDto?, + fun checkUserPathPermissionsAuth( + authentication: TolgeeAuthentication?, destination: String?, ) { val userId = @@ -102,8 +113,23 @@ class WebSocketConfig( ?.toLong() } ?: return + if (authentication == null) { + throw MessagingException("Unauthenticated") + } + + val creds = authentication.credentials + if (creds is ApiKeyDto) { + // API keys must not subscribe to user topics + throw MessagingException("Forbidden") + } + + val user = (authentication as? TolgeeAuthentication)?.principal if (user?.id != userId) { throw MessagingException("Forbidden") } } + + private fun throwForbidden() { + throw MessagingException("Forbidden") + } } diff --git a/backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt b/backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt new file mode 100644 index 0000000000..30caf7e081 --- /dev/null +++ b/backend/api/src/main/kotlin/io/tolgee/websocket/WebsocketAuthenticationResolver.kt @@ -0,0 +1,160 @@ +package io.tolgee.websocket + +import io.tolgee.component.CurrentDateProvider +import io.tolgee.constants.Message +import io.tolgee.dtos.cacheable.UserAccountDto +import io.tolgee.exceptions.AuthenticationException +import io.tolgee.security.PAT_PREFIX +import io.tolgee.security.authentication.JwtService +import io.tolgee.security.authentication.TolgeeAuthentication +import io.tolgee.service.security.ApiKeyService +import io.tolgee.service.security.PatService +import io.tolgee.service.security.UserAccountService +import io.tolgee.util.Logging +import io.tolgee.util.logger +import org.springframework.context.annotation.Lazy +import org.springframework.messaging.simp.stomp.StompHeaderAccessor +import org.springframework.stereotype.Component + +@Component +class WebsocketAuthenticationResolver( + @Lazy private val jwtService: JwtService, + @Lazy private val apiKeyService: ApiKeyService, + @Lazy private val patService: PatService, + @Lazy private val userAccountService: UserAccountService, + private val currentDateProvider: CurrentDateProvider, +) : Logging { + /** + * Resolves STOMP CONNECT headers into TolgeeAuthentication. + * Supports: + * - Authorization: Bearer + * - X-API-Key: tgpat_ (PAT) or tgpak_<...> (PAK, incl. legacy/raw) + * - jwtToken: (legacy header) + * + * It retrieves the headers from the accessor and validates them. + */ + fun resolve(accessor: StompHeaderAccessor): TolgeeAuthentication? { + val authorizationHeader = getCaseInsensitiveHeader(accessor, "authorization") + val xApiKeyHeader = getCaseInsensitiveHeader(accessor, "x-api-key") + val legacyJwtHeader = getCaseInsensitiveHeader(accessor, "jwtToken") + + // Authorization: Bearer + val bearer = extractBearer(authorizationHeader) + if (bearer != null) { + return runCatching { jwtService.validateToken(bearer) } + .onFailure { + logger.debug( + "Bearer token validation failed", + it, + ) + }.getOrNull() + } + + // X-API-Key: PAT / PAK + val xApiKey = xApiKeyHeader + if (!xApiKey.isNullOrBlank()) { + return when { + xApiKey.startsWith(PAT_PREFIX) -> + runCatching { patAuth(xApiKey) } + .onFailure { + logger.debug( + "PAT authentication failed", + it, + ) + }.getOrNull() + + else -> + runCatching { pakAuth(xApiKey) } + .onFailure { logger.debug("PAK authentication failed", it) } + .getOrNull() + } + } + + // Legacy jwtToken header + if (!legacyJwtHeader.isNullOrBlank()) { + return runCatching { jwtService.validateToken(legacyJwtHeader) } + .onFailure { + logger.debug( + "Legacy JWT validation failed", + it, + ) + }.getOrNull() + } + + return null + } + + private fun extractBearer(value: String?): String? { + if (value == null) return null + val prefix = "Bearer " + return if (value.startsWith(prefix, ignoreCase = true)) value.substring(prefix.length).trim() else null + } + + private fun pakAuth(key: String): TolgeeAuthentication { + val parsed = apiKeyService.parseApiKey(key) ?: throw AuthenticationException(Message.INVALID_PROJECT_API_KEY) + val hash = apiKeyService.hashKey(parsed) + val pak = apiKeyService.findDto(hash) ?: throw AuthenticationException(Message.INVALID_PROJECT_API_KEY) + + if (pak.expiresAt?.before(currentDateProvider.date) == true) { + throw AuthenticationException(Message.PROJECT_API_KEY_EXPIRED) + } + + val userAccount: UserAccountDto = + userAccountService.findDto(pak.userAccountId) ?: throw AuthenticationException(Message.USER_NOT_FOUND) + + apiKeyService.updateLastUsedAsync(pak.id) + + return TolgeeAuthentication( + pak, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = null, + isReadOnly = false, + ) + } + + private fun patAuth(key: String): TolgeeAuthentication { + val hash = patService.hashToken(key.substring(PAT_PREFIX.length)) + val pat = patService.findDto(hash) ?: throw AuthenticationException(Message.INVALID_PAT) + + if (pat.expiresAt?.before(currentDateProvider.date) == true) { + throw AuthenticationException(Message.PAT_EXPIRED) + } + + val userAccount: UserAccountDto = + userAccountService.findDto(pat.userAccountId) ?: throw AuthenticationException(Message.USER_NOT_FOUND) + + patService.updateLastUsedAsync(pat.id) + + return TolgeeAuthentication( + credentials = pat, + deviceId = null, + userAccount = userAccount, + actingAsUserAccount = null, + isReadOnly = false, + ) + } + + /** + * Case-insensitive header lookup for STOMP headers. + * Searches through message headers using case-insensitive comparison. + */ + private fun getCaseInsensitiveHeader( + accessor: StompHeaderAccessor, + headerName: String, + ): String? { + val messageHeaders = accessor.messageHeaders + return messageHeaders.entries + .firstOrNull { (key, value) -> + key.equals("nativeHeaders", ignoreCase = true) && value is Map<*, *> + }?.let { (_, nativeHeadersMap) -> + @Suppress("UNCHECKED_CAST") + val nativeHeaders = nativeHeadersMap as Map> + nativeHeaders.entries + .firstOrNull { (key, _) -> + key.equals(headerName, ignoreCase = true) + }?.value + ?.firstOrNull() + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCachingTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCachingTest.kt index 1e26bec773..1bc6905f49 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCachingTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/translations/v2TranslationsController/TranslationsControllerCachingTest.kt @@ -1,15 +1,19 @@ package io.tolgee.api.v2.controllers.translations.v2TranslationsController import io.tolgee.ProjectAuthControllerTest +import io.tolgee.activity.ActivityHolder import io.tolgee.development.testDataBuilder.data.TranslationsTestData import io.tolgee.fixtures.andIsNotModified import io.tolgee.fixtures.andIsOk import io.tolgee.model.enums.Scope +import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.assert +import org.assertj.core.api.Assertions import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.HttpHeaders @@ -18,8 +22,13 @@ import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.Date -@SpringBootTest @AutoConfigureMockMvc +@ContextRecreatingTest +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) class TranslationsControllerCachingTest : ProjectAuthControllerTest("/v2/projects/") { lateinit var testData: TranslationsTestData @@ -34,6 +43,9 @@ class TranslationsControllerCachingTest : ProjectAuthControllerTest("/v2/project clearForcedDate() } + @Autowired + private lateinit var activityHolder: ActivityHolder + @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW]) @Test fun `returns all with last modified`() { @@ -56,6 +68,52 @@ class TranslationsControllerCachingTest : ProjectAuthControllerTest("/v2/project performWithIsModifiedSince(lastModified).andIsNotModified } + @Test + @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW]) + fun `returns all with eTag`() { + val now = Date() + setForcedDate(now) + testDataService.saveTestData(testData.root) + userAccount = testData.user + val eTag = performAndGetETag() + Assertions.assertThat(eTag).isNotNull() + } + + @Test + @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW]) + fun `returns 304 when eTag matches`() { + val now = Date() + setForcedDate(now) + testDataService.saveTestData(testData.root) + userAccount = testData.user + val eTag = performAndGetETag() + performWithIfNoneMatch(eTag).andIsNotModified + } + + @Test + @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW]) + fun `works when data change with eTag`() { + val now = Date() + setForcedDate(now) + testDataService.saveTestData(testData.root) + userAccount = testData.user + val eTag = performAndGetETag() + performWithIfNoneMatch(eTag).andIsNotModified + + val newNow = Date(Date().time + 50000) + setForcedDate(newNow) + + executeInNewTransaction { + activityHolder.activityRevision.projectId = testData.project.id + translationService.setTranslationText(testData.aKey, testData.englishLanguage, "This was changed!") + } + val newETag = performWithIfNoneMatch(eTag).andIsOk.eTag() + Assertions.assertThat(newETag).isNotNull() + Assertions.assertThat(newETag).isNotEqualTo(eTag) + + performWithIfNoneMatch(newETag).andIsNotModified + } + @Test @ProjectApiKeyAuthTestMethod(scopes = [Scope.TRANSLATIONS_VIEW]) fun `works when data change`() { @@ -68,8 +126,11 @@ class TranslationsControllerCachingTest : ProjectAuthControllerTest("/v2/project val newNow = Date(Date().time + 50000) setForcedDate(newNow) - translationService.setTranslationText(testData.aKey, testData.englishLanguage, "This was changed!") + executeInNewTransaction { + activityHolder.activityRevision.projectId = testData.project.id + translationService.setTranslationText(testData.aKey, testData.englishLanguage, "This was changed!") + } val newLastModified = performWithIsModifiedSince(lastModified).andIsOk.lastModified() assertEqualsDate(newLastModified, newNow) @@ -83,13 +144,27 @@ class TranslationsControllerCachingTest : ProjectAuthControllerTest("/v2/project return performGet("/v2/projects/translations/en,de", headers) } + fun performWithIfNoneMatch(eTag: String?): ResultActions { + val headers = HttpHeaders() + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + headers["If-None-Match"] = eTag + return performGet("/v2/projects/translations/en,de", headers) + } + private fun performAndGetLastModified(): String? = performProjectAuthGet("/translations/en,de") .andIsOk .lastModified() + private fun performAndGetETag(): String? = + performProjectAuthGet("/translations/en,de") + .andIsOk + .eTag() + private fun ResultActions.lastModified() = this.andReturn().response.getHeader("Last-Modified") + private fun ResultActions.eTag() = this.andReturn().response.getHeader("ETag") + private fun assertEqualsDate( lastModified: String?, now: Date, diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportAllFormatsTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt similarity index 96% rename from backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportAllFormatsTest.kt rename to backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt index 2d400bbe29..b56c6163f7 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportAllFormatsTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportAllFormatsTest.kt @@ -1,4 +1,4 @@ -package io.tolgee.api.v2.controllers +package io.tolgee.api.v2.controllers.v2ExportController import io.tolgee.ProjectAuthControllerTest import io.tolgee.development.testDataBuilder.data.NamespacesTestData @@ -7,7 +7,7 @@ import io.tolgee.fixtures.ignoreTestOnSpringBug import io.tolgee.formats.ExportFormat import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod -import io.tolgee.testing.assertions.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.params.ParameterizedTest diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt new file mode 100644 index 0000000000..56024f5f58 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerCachingTest.kt @@ -0,0 +1,209 @@ +package io.tolgee.api.v2.controllers.v2ExportController + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.TranslationsTestData +import io.tolgee.fixtures.andIsOk +import io.tolgee.fixtures.retry +import io.tolgee.model.enums.Scope +import io.tolgee.testing.ContextRecreatingTest +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.transaction.annotation.Transactional + +@ContextRecreatingTest +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) +class V2ExportControllerCachingTest : ProjectAuthControllerTest("/v2/projects/") { + var testData: TranslationsTestData? = null + + @BeforeEach + fun setup() { + clearCaches() + } + + @AfterEach + fun tearDown() { + clearForcedDate() + } + + private fun initBaseData() { + testData = TranslationsTestData() + testDataService.saveTestData(testData!!.root) + prepareUserAndProject(testData!!) + } + + private fun prepareUserAndProject(testData: TranslationsTestData) { + userAccount = testData.user + projectSupplier = { testData.project } + } + + @Test + @Transactional + @ProjectJWTAuthTestMethod + fun `returns 304 for GET export when data not modified`() { + retryingOnCommonIssues { + initBaseData() + + // First request - should return data + val firstResponse = + performProjectAuthGet("export?languages=en&zip=false") + .andIsOk + .andReturn() + + val lastModifiedHeader = firstResponse.response.getHeaderValue("Last-Modified") as String + Assertions.assertThat(lastModifiedHeader).isNotNull() + + // Second request with If-Modified-Since header - should return 304 + val headers = org.springframework.http.HttpHeaders() + headers["If-Modified-Since"] = lastModifiedHeader + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + val secondResponse = performGet("/v2/projects/${project.id}/export?languages=en&zip=false", headers).andReturn() + + Assertions.assertThat(secondResponse.response.status).isEqualTo(304) + Assertions.assertThat(secondResponse.response.contentAsByteArray).isEmpty() + Assertions.assertThat(secondResponse.response.contentAsString).isEmpty() + } + } + + @Test + @Transactional + @ProjectJWTAuthTestMethod + fun `returns 304 for GET export when eTag matches`() { + retryingOnCommonIssues { + initBaseData() + + // First request - should return data + val firstResponse = + performProjectAuthGet("export?languages=en&zip=false") + .andIsOk + .andReturn() + + val eTagHeader = firstResponse.response.getHeaderValue("ETag") as String + Assertions.assertThat(eTagHeader).isNotNull() + + // Second request with If-None-Match header - should return 304 + val headers = org.springframework.http.HttpHeaders() + headers["If-None-Match"] = eTagHeader + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + val secondResponse = performGet("/v2/projects/${project.id}/export?languages=en&zip=false", headers).andReturn() + + Assertions.assertThat(secondResponse.response.status).isEqualTo(304) + Assertions.assertThat(secondResponse.response.contentAsByteArray).isEmpty() + Assertions.assertThat(secondResponse.response.contentAsString).isEmpty() + } + } + + @Test + @Transactional + @ProjectJWTAuthTestMethod + fun `returns 304 for POST export when eTag matches`() { + retryingOnCommonIssues { + initBaseData() + + // First request - should return data + val firstResponse = + performProjectAuthPost("export", mapOf("languages" to setOf("en"), "zip" to false)) + .andIsOk + .andReturn() + + val eTagHeader = firstResponse.response.getHeaderValue("ETag") as String + Assertions.assertThat(eTagHeader).isNotNull() + + // Second request with If-None-Match header - should return 304 + val headers = org.springframework.http.HttpHeaders() + headers["If-None-Match"] = eTagHeader + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + val secondResponse = + performPost( + "/v2/projects/${project.id}/export", + mapOf( + "languages" to setOf("en"), + "zip" to false, + ), + headers, + ).andReturn() + + // With custom implementation, POST requests now return 304 (Not Modified) instead of 412 + // when conditional headers indicate the data hasn't changed, since we're using POST only + // because we cannot provide all the params in the query - no actual modification occurs. + Assertions.assertThat(secondResponse.response.status).isEqualTo(304) + Assertions.assertThat(secondResponse.response.contentAsByteArray).isEmpty() + Assertions.assertThat(secondResponse.response.contentAsString).isEmpty() + } + } + + @Test + @Transactional + @ProjectJWTAuthTestMethod + fun `returns 304 for POST export when data not modified`() { + retryingOnCommonIssues { + initBaseData() + + // First request - should return data + val firstResponse = + performProjectAuthPost("export", mapOf("languages" to setOf("en"), "zip" to false)) + .andIsOk + .andReturn() + + val lastModifiedHeader = firstResponse.response.getHeaderValue("Last-Modified") as String + Assertions.assertThat(lastModifiedHeader).isNotNull() + + // Second request with If-Modified-Since header - should return 304 + val headers = org.springframework.http.HttpHeaders() + headers["If-Modified-Since"] = lastModifiedHeader + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + val secondResponse = + performPost( + "/v2/projects/${project.id}/export", + mapOf( + "languages" to setOf("en"), + "zip" to false, + ), + headers, + ).andReturn() + + // With custom implementation, POST requests now return 304 (Not Modified) instead of 412 + // when conditional headers indicate the data hasn't changed, since we're using POST only + // because we cannot provide all the params in the query - no actual modification occurs. + Assertions.assertThat(secondResponse.response.status).isEqualTo(304) + Assertions.assertThat(secondResponse.response.contentAsByteArray).isEmpty() + Assertions.assertThat(secondResponse.response.contentAsString).isEmpty() + } + } + + private fun retryingOnCommonIssues(fn: () -> Unit) { + retry( + retries = 10, + exceptionMatcher = matcher@{ + if (it is ConcurrentModificationException || + it is DataIntegrityViolationException || + it is NullPointerException + ) { + return@matcher true + } + + if (it is IllegalStateException && it.message?.contains("End size") == true) { + return@matcher true + } + + false + }, + ) { + try { + fn() + } finally { + executeInNewTransaction { + testData?.let { testDataService.cleanTestData(it.root) } + } + } + } + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt similarity index 95% rename from backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportControllerTest.kt rename to backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt index 4ff9f9c117..912014917e 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2ExportControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/v2ExportController/V2ExportControllerTest.kt @@ -1,4 +1,4 @@ -package io.tolgee.api.v2.controllers +package io.tolgee.api.v2.controllers.v2ExportController import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue @@ -20,11 +20,11 @@ import io.tolgee.fixtures.waitForNotThrowing import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod import io.tolgee.testing.assert -import io.tolgee.testing.assertions.Assertions.assertThat import io.tolgee.util.addDays import io.tolgee.util.addSeconds import net.javacrumbs.jsonunit.assertj.assertThatJson import org.assertj.core.api.AbstractIntegerAssert +import org.assertj.core.api.Assertions import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -120,9 +120,11 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { response.andPrettyPrint.andAssertThatJson { node("Z key").isEqualTo("A translation") } - assertThat(response.andReturn().response.getHeaderValue("content-type")) + Assertions + .assertThat(response.andReturn().response.getHeaderValue("content-type")) .isEqualTo("application/json") - assertThat(response.andReturn().response.getHeaderValue("content-disposition")) + Assertions + .assertThat(response.andReturn().response.getHeaderValue("content-disposition")) .isEqualTo("""attachment; filename="en.json"""") } } @@ -139,9 +141,11 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { performProjectAuthGet("export?languages=en&zip=false&format=XLIFF") .andDo { obj: MvcResult -> obj.getAsyncResult(30000) } - assertThat(response.andReturn().response.getHeaderValue("content-type")) + Assertions + .assertThat(response.andReturn().response.getHeaderValue("content-type")) .isEqualTo("application/x-xliff+xml") - assertThat(response.andReturn().response.getHeaderValue("content-disposition")) + Assertions + .assertThat(response.andReturn().response.getHeaderValue("content-disposition")) .isEqualTo("""attachment; filename="en.xliff"""") } } @@ -173,7 +177,7 @@ class V2ExportControllerTest : ProjectAuthControllerTest("/v2/projects/") { } } - assertThat(time).isLessThan(2000) + Assertions.assertThat(time).isLessThan(2000) } } diff --git a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt index 97dd8b4a19..c88dbb7f43 100644 --- a/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt +++ b/backend/app/src/test/kotlin/io/tolgee/batch/BatchJobTestUtil.kt @@ -271,7 +271,7 @@ class BatchJobTestUtil( websocketHelper = WebsocketTestHelper( port, - jwtService.emitToken(testData.user.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(testData.user.id)), testData.projectBuilder.self.id, testData.user.id, ) diff --git a/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt index 54506696e3..baefd69ef4 100644 --- a/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/controllers/ExportControllerTest.kt @@ -2,31 +2,57 @@ package io.tolgee.controllers import io.tolgee.ProjectAuthControllerTest import io.tolgee.development.testDataBuilder.data.LanguagePermissionsTestData +import io.tolgee.development.testDataBuilder.data.TranslationsTestData import io.tolgee.fixtures.andIsForbidden +import io.tolgee.fixtures.andIsNotModified import io.tolgee.fixtures.andIsOk import io.tolgee.model.Language import io.tolgee.model.enums.Scope +import io.tolgee.testing.ContextRecreatingTest import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.testing.assert import org.assertj.core.api.Assertions +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.HttpHeaders import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.ResultActions import org.springframework.test.web.servlet.result.MockMvcResultMatchers import org.springframework.transaction.annotation.Transactional import java.io.ByteArrayInputStream +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Date import java.util.function.Consumer import java.util.zip.ZipEntry import java.util.zip.ZipInputStream +@AutoConfigureMockMvc +@ContextRecreatingTest +@SpringBootTest( + properties = [ + "tolgee.cache.enabled=true", + ], +) class ExportControllerTest : ProjectAuthControllerTest() { + private lateinit var testData: TranslationsTestData + + @BeforeEach + fun setup() { + testData = TranslationsTestData() + testDataService.saveTestData(testData.root) + projectSupplier = { testData.project } + userAccount = testData.user + } + @Test @Transactional @ProjectJWTAuthTestMethod fun exportZipJson() { - val base = dbPopulator.populate() - commitTransaction() - projectSupplier = { base.project } - userAccount = base.userAccount val mvcResult = performProjectAuthGet("export/jsonZip") .andIsOk @@ -46,9 +72,6 @@ class ExportControllerTest : ProjectAuthControllerTest() { @Transactional @ProjectApiKeyAuthTestMethod fun exportZipJsonWithApiKey() { - val base = dbPopulator.populate() - commitTransaction() - projectSupplier = { base.project } val mvcResult = performProjectAuthGet("export/jsonZip") .andExpect(MockMvcResultMatchers.status().isOk) @@ -84,6 +107,84 @@ class ExportControllerTest : ProjectAuthControllerTest() { Assertions.assertThat(fileSizes).containsOnlyKeys("en.json") } + @Test + @ProjectJWTAuthTestMethod + fun `returns export with last modified header`() { + val now = Date() + setForcedDate(now) + val lastModified = performAndGetLastModified() + assertEqualsDate(lastModified, now) + } + + @Test + @ProjectJWTAuthTestMethod + fun `returns 304 when export not modified`() { + val now = Date() + setForcedDate(now) + val lastModified = performAndGetLastModified() + performWithIfModifiedSince(lastModified).andIsNotModified + } + + @Test + @ProjectJWTAuthTestMethod + fun `returns export with eTag header`() { + val now = Date() + setForcedDate(now) + val eTag = performAndGetETag() + Assertions.assertThat(eTag).isNotNull() + } + + @Test + @ProjectJWTAuthTestMethod + fun `returns 304 when export eTag matches`() { + val now = Date() + setForcedDate(now) + val eTag = performAndGetETag() + performWithIfNoneMatch(eTag).andIsNotModified + } + + @AfterEach + fun clearDate() { + clearForcedDate() + testDataService.cleanTestData(testData.root) + } + + private fun performWithIfModifiedSince(lastModified: String?): ResultActions { + val headers = HttpHeaders() + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + headers["If-Modified-Since"] = lastModified + return performGet("/api/project/export/jsonZip", headers) + } + + private fun performWithIfNoneMatch(eTag: String?): ResultActions { + val headers = HttpHeaders() + headers["x-api-key"] = apiKeyService.create(userAccount!!, scopes = setOf(Scope.TRANSLATIONS_VIEW), project).key + headers["If-None-Match"] = eTag + return performGet("/api/project/export/jsonZip", headers) + } + + private fun performAndGetLastModified(): String? = + performProjectAuthGet("export/jsonZip") + .andIsOk + .lastModified() + + private fun performAndGetETag(): String? = + performProjectAuthGet("export/jsonZip") + .andIsOk + .eTag() + + private fun ResultActions.lastModified() = this.andReturn().response.getHeader("Last-Modified") + + private fun ResultActions.eTag() = this.andReturn().response.getHeader("ETag") + + private fun assertEqualsDate( + lastModified: String?, + now: Date, + ) { + val zdt: ZonedDateTime = ZonedDateTime.parse(lastModified, DateTimeFormatter.RFC_1123_DATE_TIME) + (zdt.toInstant().toEpochMilli() / 1000).assert.isEqualTo(now.time / 1000) + } + private fun parseZip(responseContent: ByteArray): Map { val byteArrayInputStream = ByteArrayInputStream(responseContent) val zipInputStream = ZipInputStream(byteArrayInputStream) diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt index b84164254a..7937a334d6 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/AbstractWebsocketTest.kt @@ -45,14 +45,14 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" currentUserWebsocket = WebsocketTestHelper( port, - jwtService.emitToken(testData.user.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(testData.user.id)), testData.projectBuilder.self.id, testData.user.id, ) anotherUserWebsocket = WebsocketTestHelper( port, - jwtService.emitToken(anotherUser.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(anotherUser.id)), testData.projectBuilder.self.id, anotherUser.id, ) @@ -239,12 +239,13 @@ abstract class AbstractWebsocketTest : ProjectAuthControllerTest("/v2/projects/" val spyingUserWebsocket = WebsocketTestHelper( port, - jwtService.emitToken(anotherUser.id), + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(anotherUser.id)), testData.projectBuilder.self.id, // anotherUser trying to spy on other user's websocket testData.user.id, ) spyingUserWebsocket.listenForNotificationsChanged() + spyingUserWebsocket.waitForForbidden() saveNotificationForCurrentUser() assertCurrentUserReceivedMessage() diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt new file mode 100644 index 0000000000..ad5e14cb43 --- /dev/null +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketAuthenticationTest.kt @@ -0,0 +1,224 @@ +package io.tolgee.websocket + +import io.tolgee.ProjectAuthControllerTest +import io.tolgee.development.testDataBuilder.data.BaseTestData +import io.tolgee.dtos.request.key.CreateKeyDto +import io.tolgee.fixtures.andIsCreated +import io.tolgee.model.Pat +import io.tolgee.model.enums.Scope +import io.tolgee.testing.annotations.ProjectApiKeyAuthTestMethod +import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod +import io.tolgee.util.addMinutes +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.web.server.LocalServerPort +import java.util.Date + +@SpringBootTest( + properties = [ + "tolgee.websocket.use-redis=false", + ], + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class WebsocketAuthenticationTest : ProjectAuthControllerTest() { + lateinit var testData: BaseTestData + + @LocalServerPort + private val port: Int? = null + + @BeforeEach + fun before() { + testData = BaseTestData() + } + + @Test + @ProjectJWTAuthTestMethod + fun `works with JWT`() { + saveTestData() + testItWorksWithAuth( + auth = + WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(testData.user.id)), + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with invalid JWT`() { + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = + WebsocketTestHelper.Auth(jwtToken = "invalid"), + ) + } + + // we need at least keys.view permission when using JWT + @Test + @ProjectJWTAuthTestMethod + fun `forbidden with insufficient scopes on user with JWT`() { + val user2 = testData.root.addUserAccount { username = "user2" } + saveTestData() + testItIsForbiddenWithAuth( + auth = WebsocketTestHelper.Auth(jwtToken = jwtService.emitToken(user2.self.id)), + ) + } + + @Test + @ProjectApiKeyAuthTestMethod + fun `works with PAK`() { + saveTestData() + testItWorksWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = apiKey.key), + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with invalid PAK`() { + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = "invalid-api-key"), + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with expired PAK`() { + saveTestData() + // Create an expired API key by manipulating date + val expiredApiKey = + apiKeyService.create( + userAccount = testData.user, + scopes = setOf(Scope.TRANSLATIONS_VIEW, Scope.KEYS_VIEW), + project = testData.projectBuilder.self, + expiresAt = currentDateProvider.date.addMinutes(-60).time, + ) + + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = expiredApiKey.key), + ) + } + + /** for api key we need at least translations.view scope */ + @Test + @ProjectApiKeyAuthTestMethod(scopes = []) // No scopes + fun `forbidden with insufficient scopes on PAK`() { + saveTestData() + testItIsForbiddenWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = apiKey.key), + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `works with PAT token`() { + val pat = + addPatToTestData( + expiresAt = currentDateProvider.date.addMinutes(60), + ) + saveTestData() + testItWorksWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = pat.tokenWithPrefix), + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with invalid PAT`() { + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = "tgpat_invalid"), + ) + } + + @Test + @ProjectJWTAuthTestMethod + fun `unauthenticated with expired PAT`() { + val expiredPat = + addPatToTestData( + expiresAt = currentDateProvider.date.addMinutes(-60), + ) + saveTestData() + testItIsUnauthenticatedWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = expiredPat.tokenWithPrefix), + ) + } + + // we need at least keys.view permission when using PAT + @Test + @ProjectJWTAuthTestMethod + fun `forbidden with insufficient scopes on user with PAT`() { + val pat = addInsufficientPatToTestData() + saveTestData() + testItIsForbiddenWithAuth( + auth = WebsocketTestHelper.Auth(apiKey = pat.tokenWithPrefix), + ) + } + + private fun saveTestData() { + testDataService.saveTestData(testData.root) + userAccount = testData.user + projectSupplier = { testData.projectBuilder.self } + } + + fun testItWorksWithAuth(auth: WebsocketTestHelper.Auth) { + val socket = prepareSocket(auth) + socket.assertNotified( + { createKey() }, + { + assertThatJson(it.poll()).node("data").isObject + }, + ) + } + + fun testItIsForbiddenWithAuth(auth: WebsocketTestHelper.Auth) { + val socket = prepareSocket(auth) + socket.waitForForbidden() + } + + fun testItIsUnauthenticatedWithAuth(auth: WebsocketTestHelper.Auth) { + val socket = prepareSocket(auth) + socket.waitForUnauthenticated() + } + + private fun prepareSocket(auth: WebsocketTestHelper.Auth): WebsocketTestHelper { + val socket = + WebsocketTestHelper( + port, + auth, + testData.projectBuilder.self.id, + testData.user.id, + ) + + socket.listenForTranslationDataModified() + return socket + } + + fun createKey() { + performAuthPost("/v2/projects/${project.id}/keys", CreateKeyDto("test_key")) + .andIsCreated + } + + private fun addPatToTestData(expiresAt: Date): Pat { + return testData.userAccountBuilder + .addPat { + description = "Test" + this.expiresAt = expiresAt + }.self + } + + private fun addInsufficientPatToTestData(): Pat { + val user = + testData.root.addUserAccount { + username = "user2" + } + return user + .addPat { + description = "Test" + this.expiresAt = currentDateProvider.date.addMinutes(60) + }.self + } +} diff --git a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt index 63bec9665d..55b934d789 100644 --- a/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt +++ b/backend/app/src/test/kotlin/io/tolgee/websocket/WebsocketTestHelper.kt @@ -1,5 +1,6 @@ package io.tolgee.websocket +import io.tolgee.fixtures.WaitNotSatisfiedException import io.tolgee.fixtures.waitFor import io.tolgee.util.Logging import io.tolgee.util.logger @@ -20,7 +21,7 @@ import java.util.concurrent.TimeUnit class WebsocketTestHelper( val port: Int?, - val jwtToken: String, + val auth: Auth, val projectId: Long, val userId: Long, ) : Logging { @@ -57,11 +58,20 @@ class WebsocketTestHelper( .connectAsync( "http://localhost:$port/websocket", WebSocketHttpHeaders(), - StompHeaders().apply { add("jwtToken", jwtToken) }, + getAuthHeaders(), sessionHandler!!, ).get(10, TimeUnit.SECONDS) } + private fun getAuthHeaders(): StompHeaders { + return StompHeaders().apply { + when { + auth.jwtToken != null -> add("jwtToken", auth.jwtToken) + auth.apiKey != null -> add("x-api-key", auth.apiKey) + } + } + } + fun stop() { logger.info("Stopping websocket listener") try { @@ -74,12 +84,18 @@ class WebsocketTestHelper( logger.info("Stopped websocket listener") } - private class MySessionHandler( + class MySessionHandler( val dest: String, val receivedMessages: LinkedBlockingDeque, ) : StompSessionHandlerAdapter(), Logging { var subscription: StompSession.Subscription? = null + var authenticationStatus: AuthenticationStatus? = null + + enum class AuthenticationStatus { + UNAUTHENTICATED, + FORBIDDEN, + } override fun afterConnected( session: StompSession, @@ -116,6 +132,9 @@ class WebsocketTestHelper( stompHeaders: StompHeaders, o: Any?, ) { + handleForbidden(stompHeaders) + handleUnauthenticated(stompHeaders) + logger.info( "Handle Frame with stompHeaders: '{}' and payload: '{}'", stompHeaders, @@ -133,6 +152,18 @@ class WebsocketTestHelper( throw RuntimeException(e) } } + + private fun handleForbidden(stompHeaders: StompHeaders) { + if (stompHeaders.get("message")?.single() == "Forbidden") { + authenticationStatus = AuthenticationStatus.FORBIDDEN + } + } + + private fun handleUnauthenticated(stompHeaders: StompHeaders) { + if (stompHeaders.get("message")?.single() == "Unauthenticated") { + authenticationStatus = AuthenticationStatus.UNAUTHENTICATED + } + } } /** @@ -150,4 +181,34 @@ class WebsocketTestHelper( assertCallback(receivedMessages) stop() } + + fun waitForForbidden() { + waitForAuthenticationStatus(MySessionHandler.AuthenticationStatus.FORBIDDEN) + } + + fun waitForUnauthenticated() { + waitForAuthenticationStatus(MySessionHandler.AuthenticationStatus.UNAUTHENTICATED) + } + + fun waitForAuthenticationStatus(status: MySessionHandler.AuthenticationStatus) { + try { + waitFor(500) { + sessionHandler?.authenticationStatus == status + } + } catch (e: WaitNotSatisfiedException) { + logger.info("Authentication status was not $status, was: ${sessionHandler?.authenticationStatus}") + throw e + } + } + + data class Auth( + val jwtToken: String? = null, + val apiKey: String? = null, + ) { + init { + if ((jwtToken == null && apiKey == null) || (jwtToken != null && apiKey != null)) { + throw IllegalArgumentException("Either jwtToken or apiKey must be provided") + } + } + } } diff --git a/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt b/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt index 92db1bfa30..ac2f28e951 100644 --- a/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt +++ b/backend/data/src/main/kotlin/io/tolgee/model/Pat.kt @@ -1,5 +1,6 @@ package io.tolgee.model +import io.tolgee.security.PAT_PREFIX import jakarta.persistence.Entity import jakarta.persistence.Index import jakarta.persistence.ManyToOne @@ -41,4 +42,7 @@ class Pat( @ManyToOne @NotNull lateinit var userAccount: UserAccount + + val tokenWithPrefix: String? + get() = token?.let { "$PAT_PREFIX$token" } } diff --git a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt index eded147a59..dec4cc7df8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/security/SecurityService.kt @@ -81,16 +81,31 @@ class SecurityService( return Scope.expand(apiKey.scopes).toSet().intersect(projectScopes.toSet()) } + /** + * Checks if the user has required permission for the project. If no user or API key is provided, + * uses the currently authenticated user and active API key. + * Always checks permissions for the current user even when using the API key for security reasons. + * + */ fun checkProjectPermission( projectId: Long, requiredPermission: Scope, + user: UserAccountDto? = null, + apiKey: ApiKeyDto? = null, ) { + val user = user ?: activeUser // Always check for the current user even if we're using an API key for security reasons. // This prevents improper preservation of permissions. - checkProjectPermissionNoApiKey(projectId, requiredPermission, activeUser) + checkProjectPermissionNoApiKey(projectId, requiredPermission, user) - val apiKey = activeApiKey ?: return - checkProjectPermission(projectId, requiredPermission, apiKey) + val apiKey = apiKey ?: activeApiKey + apiKey ?: return + + if (apiKey.projectId != projectId) { + throw PermissionException(Message.PAK_CREATED_FOR_DIFFERENT_PROJECT) + } + + this.checkApiKeyScopes(listOf(requiredPermission), apiKey) } fun hasTaskEditScopeOrIsAssigned( @@ -136,21 +151,6 @@ class SecurityService( return assignees.isNotEmpty() && assignees[0].id == activeUser.id } - fun checkProjectPermission( - projectId: Long, - requiredScopes: Scope, - apiKey: ApiKeyDto, - ) { - checkProjectPermission(listOf(requiredScopes), apiKey) - } - - private fun checkProjectPermission( - requiredScopes: List, - apiKey: ApiKeyDto, - ) { - this.checkApiKeyScopes(requiredScopes, apiKey) - } - fun checkProjectPermissionNoApiKey( projectId: Long, requiredScope: Scope, @@ -436,18 +436,13 @@ class SecurityService( checkProjectPermission(projectId, Scope.TRANSLATIONS_EDIT) } - fun checkApiKeyScopes( - scopes: Set, - apiKey: ApiKeyDto, - ) { - checkApiKeyScopes(apiKey) { expandedScopes -> - if (!expandedScopes.toList().containsAll(scopes)) { - val missingScopes = scopes.filter { !expandedScopes.contains(it) } - throw PermissionException(missingScopes = missingScopes) - } - } - } - + /** + * Checks if API key has required scopes. + * + * It does not check whether the user has the permission to use all the scope. This needs to be done separately. + * + * If you need to check both, use [checkProjectPermission] function. + */ fun checkApiKeyScopes( scopes: Collection, apiKey: ApiKeyDto, diff --git a/webapp/src/websocket-client/WebsocketClient.ts b/webapp/src/websocket-client/WebsocketClient.ts index 305107dbaa..ad9a61afe6 100644 --- a/webapp/src/websocket-client/WebsocketClient.ts +++ b/webapp/src/websocket-client/WebsocketClient.ts @@ -4,7 +4,7 @@ import { components } from 'tg.service/apiSchema.generated'; type BatchJobModelStatus = components['schemas']['BatchJobModel']['status']; -type TranslationsClientOptions = { +type WebsocketClientOptions = { serverUrl?: string; authentication: { jwtToken: string; @@ -27,7 +27,7 @@ type Subscription = { unsubscribe?: () => void; }; -export const WebsocketClient = (options: TranslationsClientOptions) => { +export const WebsocketClient = (options: WebsocketClientOptions) => { options.serverUrl = options.serverUrl || window.origin; let _client: CompatClient | undefined; @@ -96,12 +96,12 @@ export const WebsocketClient = (options: TranslationsClientOptions) => { options.onError?.(); }; - client.connect( - options.authentication.jwtToken ? { ...options.authentication } : null, - onConnected, - onError, - onDisconnect - ); + const headers: Record | null = { + jwtToken: options.authentication.jwtToken, + Authorization: `Bearer ${options.authentication.jwtToken}`, + }; + + client.connect(headers, onConnected, onError, onDisconnect); } const getClient = () => {