diff --git a/api/openapi.yaml b/api/openapi.yaml index af4c2ef..912f8a8 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -646,12 +646,25 @@ components: allOf: - $ref: "#/components/schemas/RecipeInput" - type: object - required: [id] + required: [id, createdAt, editedAt] properties: id: type: integer format: int64 minimum: 1 + createdAt: + type: string + format: date-time + description: When the recipe was saved (UTC) + editedAt: + type: string + format: date-time + description: When the recipe was last edited (UTC) + openedAt: + type: string + format: date-time + nullable: true + description: When the recipe was last opened (UTC) RecipeRequest: type: object diff --git a/services/py-help-service/client/cooking_assistant_api_client/models/recipe.py b/services/py-help-service/client/cooking_assistant_api_client/models/recipe.py index cf0119f..858c1c7 100644 --- a/services/py-help-service/client/cooking_assistant_api_client/models/recipe.py +++ b/services/py-help-service/client/cooking_assistant_api_client/models/recipe.py @@ -1,9 +1,11 @@ from __future__ import annotations +import datetime from collections.abc import Mapping from typing import TYPE_CHECKING, Any, TypeVar, cast from attrs import define as _attrs_define +from dateutil.parser import isoparse from ..types import UNSET, Unset @@ -24,7 +26,10 @@ class Recipe: instructions (list[str]): portions (float): id (int): + created_at (datetime.datetime): When the recipe was saved (UTC) + edited_at (datetime.datetime): When the recipe was last edited (UTC) nutrients (RecipeNutrients | Unset): + opened_at (datetime.datetime | None | Unset): When the recipe was last opened (UTC) """ title: str @@ -32,7 +37,10 @@ class Recipe: instructions: list[str] portions: float id: int + created_at: datetime.datetime + edited_at: datetime.datetime nutrients: RecipeNutrients | Unset = UNSET + opened_at: datetime.datetime | None | Unset = UNSET def to_dict(self) -> dict[str, Any]: title = self.title @@ -48,10 +56,22 @@ def to_dict(self) -> dict[str, Any]: id = self.id + created_at = self.created_at.isoformat() + + edited_at = self.edited_at.isoformat() + nutrients: dict[str, Any] | Unset = UNSET if not isinstance(self.nutrients, Unset): nutrients = self.nutrients.to_dict() + opened_at: None | str | Unset + if isinstance(self.opened_at, Unset): + opened_at = UNSET + elif isinstance(self.opened_at, datetime.datetime): + opened_at = self.opened_at.isoformat() + else: + opened_at = self.opened_at + field_dict: dict[str, Any] = {} field_dict.update( @@ -61,10 +81,14 @@ def to_dict(self) -> dict[str, Any]: "instructions": instructions, "portions": portions, "id": id, + "createdAt": created_at, + "editedAt": edited_at, } ) if nutrients is not UNSET: field_dict["nutrients"] = nutrients + if opened_at is not UNSET: + field_dict["openedAt"] = opened_at return field_dict @@ -89,6 +113,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: id = d.pop("id") + created_at = isoparse(d.pop("createdAt")) + + edited_at = isoparse(d.pop("editedAt")) + _nutrients = d.pop("nutrients", UNSET) nutrients: RecipeNutrients | Unset if isinstance(_nutrients, Unset): @@ -96,13 +124,33 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: else: nutrients = RecipeNutrients.from_dict(_nutrients) + def _parse_opened_at(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + opened_at_type_0 = isoparse(data) + + return opened_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + opened_at = _parse_opened_at(d.pop("openedAt", UNSET)) + recipe = cls( title=title, ingredients=ingredients, instructions=instructions, portions=portions, id=id, + created_at=created_at, + edited_at=edited_at, nutrients=nutrients, + opened_at=opened_at, ) return recipe diff --git a/services/py-help-service/requirements.txt b/services/py-help-service/requirements.txt index e550708..0af2649 100644 --- a/services/py-help-service/requirements.txt +++ b/services/py-help-service/requirements.txt @@ -4,4 +4,5 @@ langchain-google-genai==2.1.12 langchain-core==1.4.0 pydantic==2.7.4 python-dotenv==1.0.1 -attrs \ No newline at end of file +attrs +python-dateutil \ No newline at end of file diff --git a/services/py-recipe-service/client/cooking_assistant_api_client/models/recipe.py b/services/py-recipe-service/client/cooking_assistant_api_client/models/recipe.py index cf0119f..858c1c7 100644 --- a/services/py-recipe-service/client/cooking_assistant_api_client/models/recipe.py +++ b/services/py-recipe-service/client/cooking_assistant_api_client/models/recipe.py @@ -1,9 +1,11 @@ from __future__ import annotations +import datetime from collections.abc import Mapping from typing import TYPE_CHECKING, Any, TypeVar, cast from attrs import define as _attrs_define +from dateutil.parser import isoparse from ..types import UNSET, Unset @@ -24,7 +26,10 @@ class Recipe: instructions (list[str]): portions (float): id (int): + created_at (datetime.datetime): When the recipe was saved (UTC) + edited_at (datetime.datetime): When the recipe was last edited (UTC) nutrients (RecipeNutrients | Unset): + opened_at (datetime.datetime | None | Unset): When the recipe was last opened (UTC) """ title: str @@ -32,7 +37,10 @@ class Recipe: instructions: list[str] portions: float id: int + created_at: datetime.datetime + edited_at: datetime.datetime nutrients: RecipeNutrients | Unset = UNSET + opened_at: datetime.datetime | None | Unset = UNSET def to_dict(self) -> dict[str, Any]: title = self.title @@ -48,10 +56,22 @@ def to_dict(self) -> dict[str, Any]: id = self.id + created_at = self.created_at.isoformat() + + edited_at = self.edited_at.isoformat() + nutrients: dict[str, Any] | Unset = UNSET if not isinstance(self.nutrients, Unset): nutrients = self.nutrients.to_dict() + opened_at: None | str | Unset + if isinstance(self.opened_at, Unset): + opened_at = UNSET + elif isinstance(self.opened_at, datetime.datetime): + opened_at = self.opened_at.isoformat() + else: + opened_at = self.opened_at + field_dict: dict[str, Any] = {} field_dict.update( @@ -61,10 +81,14 @@ def to_dict(self) -> dict[str, Any]: "instructions": instructions, "portions": portions, "id": id, + "createdAt": created_at, + "editedAt": edited_at, } ) if nutrients is not UNSET: field_dict["nutrients"] = nutrients + if opened_at is not UNSET: + field_dict["openedAt"] = opened_at return field_dict @@ -89,6 +113,10 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: id = d.pop("id") + created_at = isoparse(d.pop("createdAt")) + + edited_at = isoparse(d.pop("editedAt")) + _nutrients = d.pop("nutrients", UNSET) nutrients: RecipeNutrients | Unset if isinstance(_nutrients, Unset): @@ -96,13 +124,33 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: else: nutrients = RecipeNutrients.from_dict(_nutrients) + def _parse_opened_at(data: object) -> datetime.datetime | None | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + try: + if not isinstance(data, str): + raise TypeError() + opened_at_type_0 = isoparse(data) + + return opened_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None | Unset, data) + + opened_at = _parse_opened_at(d.pop("openedAt", UNSET)) + recipe = cls( title=title, ingredients=ingredients, instructions=instructions, portions=portions, id=id, + created_at=created_at, + edited_at=edited_at, nutrients=nutrients, + opened_at=opened_at, ) return recipe diff --git a/services/py-recipe-service/requirements.txt b/services/py-recipe-service/requirements.txt index e862905..017be7f 100644 --- a/services/py-recipe-service/requirements.txt +++ b/services/py-recipe-service/requirements.txt @@ -5,4 +5,5 @@ langchain-core==1.4.0 pydantic==2.7.4 python-dotenv==1.0.1 attrs -httpx \ No newline at end of file +httpx +python-dateutil \ No newline at end of file diff --git a/services/spring-api/.openapi-generator/FILES b/services/spring-api/.openapi-generator/FILES index a333abc..aec68ff 100644 --- a/services/spring-api/.openapi-generator/FILES +++ b/services/spring-api/.openapi-generator/FILES @@ -3,7 +3,6 @@ gradle/wrapper/gradle-wrapper.jar gradle/wrapper/gradle-wrapper.properties gradlew gradlew.bat -pom.xml settings.gradle src/main/kotlin/org/openapitools/SpringDocConfiguration.kt src/main/kotlin/org/openapitools/api/AIApi.kt diff --git a/services/spring-api/src/main/kotlin/org/openapitools/Application.kt b/services/spring-api/src/main/kotlin/org/openapitools/Application.kt index 6f83fae..e63f8b7 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/Application.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/Application.kt @@ -3,8 +3,10 @@ package org.openapitools import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication import org.springframework.context.annotation.ComponentScan +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling @ComponentScan(basePackages = ["org.openapitools", "org.openapitools.api", "org.openapitools.model"]) class Application diff --git a/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt b/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt index 68b7a43..32ae3c7 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/api/AIApiController.kt @@ -9,6 +9,7 @@ import org.openapitools.model.RecipeRequest import org.openapitools.model.UserPreferences import org.openapitools.model.UserProfile import org.openapitools.repository.UserRepository +import org.slf4j.LoggerFactory import org.springframework.core.ParameterizedTypeReference import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -30,42 +31,57 @@ class AIApiController( private val userRepository: UserRepository, private val objectMapper: ObjectMapper, ) : AIApi { + private val log = LoggerFactory.getLogger(javaClass) + // Cap how long we wait on the GenAI service before returning an error private val aiTimeout = Duration.ofSeconds(60) override fun aiHelpPost( @Valid helpRequest: HelpRequest, ): ResponseEntity { - val user = userRepository.findByUsername(currentUsername()).orElseThrow() + val username = currentUsername() + log.info("Help request [user={}, promptLength={}]", username, helpRequest.prompt.length) + val user = userRepository.findByUsername(username).orElseThrow() val response = aiHelpWebClient .post() .uri("/ai/help") .contentType(MediaType.APPLICATION_JSON) - .bodyValue(mapOf("profile" to user.toProfile(), "recipe" to helpRequest.recipe, "prompt" to helpRequest.prompt)) - .retrieve() + .bodyValue( + objectMapper.writeValueAsString( + mapOf( + "profile" to user.toProfile(), + "recipe" to helpRequest.recipe, + "prompt" to helpRequest.prompt, + ), + ), + ).retrieve() .bodyToMono(HelpResponse::class.java) .timeout(aiTimeout) .onErrorMap(TimeoutException::class.java) { GatewayTimeoutException("GenAI service timed out") } .block() ?: throw BadGatewayException("GenAI service unavailable or returned an unparseable response") + log.info("Help response delivered [user={}]", username) return ResponseEntity.ok(response) } override fun aiRecipesPost( @Valid recipeRequest: RecipeRequest, ): ResponseEntity> { - val user = userRepository.findByUsername(currentUsername()).orElseThrow() + val username = currentUsername() + log.info("Recipe generation request [user={}, promptLength={}]", username, recipeRequest.prompt.length) + val user = userRepository.findByUsername(username).orElseThrow() val recipes = aiRecipeWebClient .post() .uri("/ai/recipes") .contentType(MediaType.APPLICATION_JSON) - .bodyValue(mapOf("profile" to user.toProfile(), "prompt" to recipeRequest.prompt)) + .bodyValue(objectMapper.writeValueAsString(mapOf("profile" to user.toProfile(), "prompt" to recipeRequest.prompt))) .retrieve() .bodyToMono(object : ParameterizedTypeReference>() {}) .timeout(aiTimeout) .onErrorMap(TimeoutException::class.java) { GatewayTimeoutException("GenAI service timed out") } .block() ?: throw BadGatewayException("GenAI service unavailable or returned an unparseable response") + log.info("Recipe generation complete [user={}, count={}]", username, recipes.size) return ResponseEntity.ok(recipes) } diff --git a/services/spring-api/src/main/kotlin/org/openapitools/api/ExceptionHandlers.kt b/services/spring-api/src/main/kotlin/org/openapitools/api/ExceptionHandlers.kt index 819c84d..a9567ad 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/api/ExceptionHandlers.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/api/ExceptionHandlers.kt @@ -2,6 +2,7 @@ package org.openapitools.api import jakarta.validation.ConstraintViolationException import org.openapitools.model.ErrorResponse +import org.slf4j.LoggerFactory import org.springframework.core.Ordered import org.springframework.core.annotation.Order import org.springframework.http.HttpHeaders @@ -15,6 +16,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.context.request.WebRequest import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException import org.springframework.web.reactive.function.client.WebClientException +import org.springframework.web.reactive.function.client.WebClientResponseException import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler // Additional exception subclasses not generated by the OpenAPI generator @@ -42,13 +44,22 @@ class GatewayTimeoutException( @Order(Ordered.HIGHEST_PRECEDENCE) @ControllerAdvice class ApiExceptionHandler : ResponseEntityExceptionHandler() { + private val log = LoggerFactory.getLogger(javaClass) + @ExceptionHandler(value = [ApiException::class]) - fun onApiException(ex: ApiException): ResponseEntity = - ResponseEntity.status(ex.code).body(ErrorResponse(ex.message ?: "An error occurred")) + fun onApiException(ex: ApiException): ResponseEntity { + when { + ex.code >= 500 -> log.error("Server error [status={}]: {}", ex.code, ex.message) + ex.code == 401 || ex.code == 403 -> log.warn("Auth error [status={}]: {}", ex.code, ex.message) + } + return ResponseEntity.status(ex.code).body(ErrorResponse(ex.message ?: "An error occurred")) + } @ExceptionHandler(value = [NotImplementedError::class]) - fun onNotImplemented(ex: NotImplementedError): ResponseEntity = - ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(ErrorResponse("Not implemented")) + fun onNotImplemented(ex: NotImplementedError): ResponseEntity { + log.warn("Not implemented endpoint hit") + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(ErrorResponse("Not implemented")) + } // Triggered by @Valid on @RequestBody when a field constraint fails override fun handleMethodArgumentNotValid( @@ -83,8 +94,15 @@ class ApiExceptionHandler : ResponseEntityExceptionHandler() { // Triggered when the AI service is unreachable or returns an HTTP error @ExceptionHandler(value = [WebClientException::class]) - fun onWebClientException(ex: WebClientException): ResponseEntity = - ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(ErrorResponse("GenAI service unavailable or returned an unparseable response")) + fun onWebClientException(ex: WebClientException): ResponseEntity { + val responseBody = (ex as? WebClientResponseException)?.responseBodyAsString + if (responseBody != null) { + log.error("AI service error: {} | body: {}", ex.message, responseBody) + } else { + log.error("AI service unavailable: {}", ex.message) + } + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(ErrorResponse("GenAI service unavailable or returned an unparseable response")) + } // Fallback for all other standard Spring MVC exceptions (405, 406, 415, 404, etc.) override fun handleExceptionInternal( @@ -93,5 +111,8 @@ class ApiExceptionHandler : ResponseEntityExceptionHandler() { headers: HttpHeaders, statusCode: HttpStatusCode, request: WebRequest, - ): ResponseEntity = ResponseEntity.status(statusCode).headers(headers).body(ErrorResponse(ex.message ?: "An error occurred")) + ): ResponseEntity { + if (statusCode.is5xxServerError) log.error("Unhandled error [status={}]", statusCode.value(), ex) + return ResponseEntity.status(statusCode).headers(headers).body(ErrorResponse(ex.message ?: "An error occurred")) + } } diff --git a/services/spring-api/src/main/kotlin/org/openapitools/api/RecipesApiController.kt b/services/spring-api/src/main/kotlin/org/openapitools/api/RecipesApiController.kt index 421a79d..4e0e39c 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/api/RecipesApiController.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/api/RecipesApiController.kt @@ -9,6 +9,7 @@ import org.openapitools.model.RecipeNutrients import org.openapitools.model.RecipeUpdate import org.openapitools.repository.RecipeRepository import org.openapitools.repository.UserRepository +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.core.context.SecurityContextHolder @@ -17,6 +18,8 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import tools.jackson.core.type.TypeReference import tools.jackson.databind.ObjectMapper +import java.time.Instant +import java.time.ZoneOffset @RestController @Validated @@ -26,6 +29,8 @@ class RecipesApiController( private val userRepository: UserRepository, private val objectMapper: ObjectMapper, ) : RecipesApi { + private val log = LoggerFactory.getLogger(javaClass) + override fun recipesGet(): ResponseEntity> { val user = userRepository.findByUsername(currentUsername()).orElseThrow() return ResponseEntity.ok(recipeRepository.findByUserId(user.id).map { it.toApiModel() }) @@ -47,6 +52,7 @@ class RecipesApiController( user = user, ), ) + log.info("Recipe saved [user={}, recipeId={}, title='{}']", user.username, saved.id, saved.title) return ResponseEntity.status(HttpStatus.CREATED).body(RecipeCreated(id = saved.id)) } @@ -54,6 +60,8 @@ class RecipesApiController( val entity = recipeRepository.findById(recipeId).orElseThrow { NotFoundException("Recipe not found") } val user = userRepository.findByUsername(currentUsername()).orElseThrow() if (entity.user.id != user.id) throw ForbiddenException("Recipe belongs to a different user") + entity.openedAt = Instant.now() + recipeRepository.save(entity) return ResponseEntity.ok(entity.toApiModel()) } @@ -74,6 +82,8 @@ class RecipesApiController( entity.nutrientProt = it.protein entity.nutrientFat = it.fat } + entity.editedAt = Instant.now() + log.info("Recipe updated [user={}, recipeId={}]", user.username, recipeId) return ResponseEntity.ok(recipeRepository.save(entity).toApiModel()) } @@ -82,6 +92,7 @@ class RecipesApiController( val user = userRepository.findByUsername(currentUsername()).orElseThrow() if (entity.user.id != user.id) throw ForbiddenException("Recipe belongs to a different user") recipeRepository.delete(entity) + log.info("Recipe deleted [user={}, recipeId={}]", user.username, recipeId) return ResponseEntity(HttpStatus.NO_CONTENT) } @@ -101,5 +112,8 @@ class RecipesApiController( protein = nutrientProt, fat = nutrientFat, ), + createdAt = createdAt.atOffset(ZoneOffset.UTC), + editedAt = editedAt.atOffset(ZoneOffset.UTC), + openedAt = openedAt?.atOffset(ZoneOffset.UTC), ) } diff --git a/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt b/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt index 6fc9a55..473b4e0 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/api/UsersApiController.kt @@ -1,14 +1,17 @@ package org.openapitools.api import jakarta.validation.Valid +import org.openapitools.entity.TokenBlocklistEntry import org.openapitools.entity.UserEntity import org.openapitools.model.AuthRequest import org.openapitools.model.AuthResponse import org.openapitools.model.UserPreferences import org.openapitools.model.UserProfile import org.openapitools.model.UserProfileUpdate +import org.openapitools.repository.TokenBlocklistRepository import org.openapitools.repository.UserRepository import org.openapitools.security.JwtUtils +import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.security.authentication.AuthenticationManager @@ -30,7 +33,10 @@ class UsersApiController( private val authManager: AuthenticationManager, private val jwtUtils: JwtUtils, private val objectMapper: ObjectMapper, + private val tokenBlocklist: TokenBlocklistRepository, ) : UsersApi { + private val log = LoggerFactory.getLogger(javaClass) + override fun usersRegisterPost( @Valid authRequest: AuthRequest, ): ResponseEntity { @@ -44,6 +50,7 @@ class UsersApiController( preferences = objectMapper.writeValueAsString(UserPreferences()), ), ) + log.info("User registered [user={}]", authRequest.username) return ResponseEntity(HttpStatus.CREATED) } @@ -58,13 +65,24 @@ class UsersApiController( throw UnauthorizedException("Invalid username or password") } val user = userRepository.findByUsername(authRequest.username).orElseThrow() + log.info("User logged in [user={}]", authRequest.username) return ResponseEntity.ok(AuthResponse(token = jwtUtils.generateToken(user.id))) } - override fun usersLogoutPost(): ResponseEntity = - // JWTs are stateless — the client simply discards the token. - // For true server-side invalidation you'd need a token blocklist (e.g. Redis). - ResponseEntity(HttpStatus.OK) + override fun usersLogoutPost(): ResponseEntity { + val username = currentUsername() + val token = SecurityContextHolder.getContext().authentication?.credentials as? String + if (token != null) { + tokenBlocklist.save( + TokenBlocklistEntry( + tokenHash = jwtUtils.tokenHash(token), + expiresAt = jwtUtils.getExpirationFromToken(token), + ), + ) + } + log.info("User logged out [user={}]", username) + return ResponseEntity(HttpStatus.OK) + } override fun usersProfileGet(): ResponseEntity { val user = userRepository.findByUsername(currentUsername()).orElseThrow() diff --git a/services/spring-api/src/main/kotlin/org/openapitools/entity/RecipeEntity.kt b/services/spring-api/src/main/kotlin/org/openapitools/entity/RecipeEntity.kt index 888c5b2..c313a87 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/entity/RecipeEntity.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/entity/RecipeEntity.kt @@ -1,6 +1,7 @@ package org.openapitools.entity import jakarta.persistence.* +import java.time.Instant @Entity @Table(name = "recipes") @@ -19,6 +20,11 @@ class RecipeEntity( var nutrientCarb: Int, var nutrientProt: Int, var nutrientFat: Int, + @Column(nullable = false, updatable = false) + val createdAt: Instant = Instant.now(), + @Column(nullable = false) + var editedAt: Instant = Instant.now(), + var openedAt: Instant? = null, @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) val user: UserEntity, diff --git a/services/spring-api/src/main/kotlin/org/openapitools/entity/TokenBlocklistEntry.kt b/services/spring-api/src/main/kotlin/org/openapitools/entity/TokenBlocklistEntry.kt new file mode 100644 index 0000000..6aae95e --- /dev/null +++ b/services/spring-api/src/main/kotlin/org/openapitools/entity/TokenBlocklistEntry.kt @@ -0,0 +1,16 @@ +package org.openapitools.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant + +@Entity +@Table(name = "token_blocklist") +class TokenBlocklistEntry( + @Id + val tokenHash: String, + @Column(nullable = false) + val expiresAt: Instant, +) diff --git a/services/spring-api/src/main/kotlin/org/openapitools/model/Recipe.kt b/services/spring-api/src/main/kotlin/org/openapitools/model/Recipe.kt index e65f77d..d6534cc 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/model/Recipe.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/model/Recipe.kt @@ -22,7 +22,10 @@ import java.util.Objects * @param instructions * @param portions * @param id + * @param createdAt When the recipe was saved (UTC) + * @param editedAt When the recipe was last edited (UTC) * @param nutrients + * @param openedAt When the recipe was last opened (UTC) */ data class Recipe( @get:Size(min = 1, max = 255) @@ -41,7 +44,13 @@ data class Recipe( @get:Min(value = 1L) @Schema(example = "null", required = true, description = "") @get:JsonProperty("id", required = true) val id: kotlin.Long, + @Schema(example = "null", required = true, description = "When the recipe was saved (UTC)") + @get:JsonProperty("createdAt", required = true) val createdAt: java.time.OffsetDateTime, + @Schema(example = "null", required = true, description = "When the recipe was last edited (UTC)") + @get:JsonProperty("editedAt", required = true) val editedAt: java.time.OffsetDateTime, @field:Valid @Schema(example = "null", description = "") @get:JsonProperty("nutrients") val nutrients: RecipeNutrients? = null, + @Schema(example = "null", description = "When the recipe was last opened (UTC)") + @get:JsonProperty("openedAt") val openedAt: java.time.OffsetDateTime? = null, ) diff --git a/services/spring-api/src/main/kotlin/org/openapitools/repository/TokenBlocklistRepository.kt b/services/spring-api/src/main/kotlin/org/openapitools/repository/TokenBlocklistRepository.kt new file mode 100644 index 0000000..d32a1ff --- /dev/null +++ b/services/spring-api/src/main/kotlin/org/openapitools/repository/TokenBlocklistRepository.kt @@ -0,0 +1,9 @@ +package org.openapitools.repository + +import org.openapitools.entity.TokenBlocklistEntry +import org.springframework.data.jpa.repository.JpaRepository +import java.time.Instant + +interface TokenBlocklistRepository : JpaRepository { + fun deleteAllByExpiresAtBefore(instant: Instant): Long +} diff --git a/services/spring-api/src/main/kotlin/org/openapitools/security/JwtAuthFilter.kt b/services/spring-api/src/main/kotlin/org/openapitools/security/JwtAuthFilter.kt index 538728f..3d11594 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/security/JwtAuthFilter.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/security/JwtAuthFilter.kt @@ -3,38 +3,51 @@ package org.openapitools.security import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.openapitools.repository.TokenBlocklistRepository import org.openapitools.repository.UserRepository +import org.slf4j.LoggerFactory import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.userdetails.User import org.springframework.web.filter.OncePerRequestFilter -// Runs on every incoming request and checks the Authorization header for a valid JWT. -// If valid, it marks the request as authenticated so Spring Security lets it through. class JwtAuthFilter( private val jwtUtils: JwtUtils, private val userRepository: UserRepository, + private val tokenBlocklist: TokenBlocklistRepository, ) : OncePerRequestFilter() { + private val log = LoggerFactory.getLogger(javaClass) + override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain, ) { - // Expects: Authorization: Bearer val header = request.getHeader("Authorization") if (header != null && header.startsWith("Bearer ")) { val token = header.removePrefix("Bearer ") - if (jwtUtils.validateToken(token)) { - val userId = jwtUtils.getUserIdFromToken(token) - userRepository.findById(userId).ifPresent { entity -> - val userDetails = - User - .withUsername(entity.username) - .password(entity.password) - .roles("USER") - .build() - val auth = UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) - SecurityContextHolder.getContext().authentication = auth + when { + !jwtUtils.validateToken(token) -> { + log.warn("Rejected invalid JWT [path={}]", request.requestURI) + } + + tokenBlocklist.existsById(jwtUtils.tokenHash(token)) -> { + log.warn("Rejected invalidated JWT [path={}]", request.requestURI) + } + + else -> { + val userId = jwtUtils.getUserIdFromToken(token) + userRepository.findById(userId).ifPresent { entity -> + val userDetails = + User + .withUsername(entity.username) + .password(entity.password) + .roles("USER") + .build() + // Store the raw token as credentials so logout can retrieve and blocklist it. + val auth = UsernamePasswordAuthenticationToken(userDetails, token, userDetails.authorities) + SecurityContextHolder.getContext().authentication = auth + } } } } diff --git a/services/spring-api/src/main/kotlin/org/openapitools/security/JwtUtils.kt b/services/spring-api/src/main/kotlin/org/openapitools/security/JwtUtils.kt index 2a5a749..dec7f67 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/security/JwtUtils.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/security/JwtUtils.kt @@ -5,6 +5,8 @@ import io.jsonwebtoken.Jwts import io.jsonwebtoken.security.Keys import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component +import java.security.MessageDigest +import java.time.Instant import java.util.Date @Component @@ -46,4 +48,18 @@ class JwtUtils { } catch (e: JwtException) { false } + + fun getExpirationFromToken(token: String): Instant = + Jwts + .parser() + .verifyWith(key()) + .build() + .parseSignedClaims(token) + .payload.expiration + .toInstant() + + fun tokenHash(token: String): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(token.toByteArray()) + return bytes.joinToString("") { "%02x".format(it) } + } } diff --git a/services/spring-api/src/main/kotlin/org/openapitools/security/SecurityConfig.kt b/services/spring-api/src/main/kotlin/org/openapitools/security/SecurityConfig.kt index c753ab1..26053c1 100644 --- a/services/spring-api/src/main/kotlin/org/openapitools/security/SecurityConfig.kt +++ b/services/spring-api/src/main/kotlin/org/openapitools/security/SecurityConfig.kt @@ -1,5 +1,6 @@ package org.openapitools.security +import org.openapitools.repository.TokenBlocklistRepository import org.openapitools.repository.UserRepository import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -20,6 +21,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic class SecurityConfig( private val userRepository: UserRepository, private val jwtUtils: JwtUtils, + private val tokenBlocklist: TokenBlocklistRepository, ) { // BCrypt is the standard algorithm for hashing passwords @Bean @@ -78,7 +80,7 @@ class SecurityConfig( } }.headers { it.frameOptions { fo -> fo.disable() } } // needed for H2 console iframe .addFilterBefore( - JwtAuthFilter(jwtUtils, userRepository), + JwtAuthFilter(jwtUtils, userRepository, tokenBlocklist), UsernamePasswordAuthenticationFilter::class.java, ) return http.build() diff --git a/services/spring-api/src/main/kotlin/org/openapitools/security/TokenBlocklistScheduler.kt b/services/spring-api/src/main/kotlin/org/openapitools/security/TokenBlocklistScheduler.kt new file mode 100644 index 0000000..1b73324 --- /dev/null +++ b/services/spring-api/src/main/kotlin/org/openapitools/security/TokenBlocklistScheduler.kt @@ -0,0 +1,20 @@ +package org.openapitools.security + +import org.openapitools.repository.TokenBlocklistRepository +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.Instant + +@Component +class TokenBlocklistScheduler( + private val tokenBlocklist: TokenBlocklistRepository, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Scheduled(fixedDelay = 3_600_000) + fun purgeExpired() { + val count = tokenBlocklist.deleteAllByExpiresAtBefore(Instant.now()) + if (count > 0) log.info("Purged {} expired token blocklist entries", count) + } +} diff --git a/services/spring-api/src/main/resources/application.yaml b/services/spring-api/src/main/resources/application.yaml index 268399e..bdbfc3a 100644 --- a/services/spring-api/src/main/resources/application.yaml +++ b/services/spring-api/src/main/resources/application.yaml @@ -49,3 +49,12 @@ app: jwt: secret: ${JWT_SECRET:local-dev-secret-change-this-in-production-use-env-var} expiration-ms: 86400000 # 24 hours + +logging: + structured: + format: + console: ecs # JSON structured logs — comment out for plain-text + level: + root: INFO + org.springframework.security: WARN + org.hibernate: WARN diff --git a/services/spring-api/src/test/kotlin/org/openapitools/api/ApiTestBase.kt b/services/spring-api/src/test/kotlin/org/openapitools/api/ApiTestBase.kt index a05edb4..ec831c4 100644 --- a/services/spring-api/src/test/kotlin/org/openapitools/api/ApiTestBase.kt +++ b/services/spring-api/src/test/kotlin/org/openapitools/api/ApiTestBase.kt @@ -1,6 +1,7 @@ package org.openapitools.api import org.junit.jupiter.api.BeforeEach +import org.openapitools.repository.TokenBlocklistRepository import org.openapitools.repository.UserRepository import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -24,8 +25,11 @@ abstract class ApiTestBase { @Autowired lateinit var userRepository: UserRepository + @Autowired lateinit var tokenBlocklist: TokenBlocklistRepository + @BeforeEach fun cleanupDatabase() { + tokenBlocklist.deleteAll() userRepository.deleteAll() } diff --git a/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt b/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt index 109dc54..7a2955b 100644 --- a/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt +++ b/services/spring-api/src/test/kotlin/org/openapitools/api/UsersApiTest.kt @@ -125,6 +125,17 @@ class UsersApiTest : ApiTestBase() { ).andExpect(status().isOk) } + @Test + fun `logout - token is blocked and subsequent requests return 401`() { + val token = register() + mockMvc + .perform(post("/api/v1/users/logout").header("Authorization", "Bearer $token")) + .andExpect(status().isOk) + mockMvc + .perform(get("/api/v1/users/profile").header("Authorization", "Bearer $token")) + .andExpect(status().isUnauthorized) + } + @Test fun `logout - unauthenticated returns 401`() { mockMvc diff --git a/web-client/src/api.ts b/web-client/src/api.ts index c529fcd..55d467b 100644 --- a/web-client/src/api.ts +++ b/web-client/src/api.ts @@ -781,6 +781,21 @@ export interface components { Recipe: components["schemas"]["RecipeInput"] & { /** Format: int64 */ id: number; + /** + * Format: date-time + * @description When the recipe was saved (UTC) + */ + createdAt: string; + /** + * Format: date-time + * @description When the recipe was last edited (UTC) + */ + editedAt: string; + /** + * Format: date-time + * @description When the recipe was last opened (UTC) + */ + openedAt?: string | null; }; RecipeRequest: { prompt: string; diff --git a/web-client/tests/pages/GeneratePage.test.tsx b/web-client/tests/pages/GeneratePage.test.tsx index 0b75c6e..ba4ce1f 100644 --- a/web-client/tests/pages/GeneratePage.test.tsx +++ b/web-client/tests/pages/GeneratePage.test.tsx @@ -15,6 +15,8 @@ const recipe: Recipe = { ingredients: [{ name: 'tomato', quantity: 4, unit: 'pcs' }], instructions: ['boil pasta', 'add sauce'], nutrients: { calories: 0, protein: 0, fat: 0, carbs: 0 }, + createdAt: '2024-01-01T00:00:00Z', + editedAt: '2024-01-01T00:00:00Z', } const fetchMock = vi.fn() diff --git a/web-client/tests/pages/RecipePage.test.tsx b/web-client/tests/pages/RecipePage.test.tsx index 306a47e..f0228f4 100644 --- a/web-client/tests/pages/RecipePage.test.tsx +++ b/web-client/tests/pages/RecipePage.test.tsx @@ -16,6 +16,8 @@ const a: Recipe = { ingredients: [{name: 'tomato', quantity: 4, unit: 'pcs'}], instructions: ['boil pasta', 'add sauce'], nutrients: {calories: 500, protein: 20, fat: 10, carbs: 60}, + createdAt: '2024-01-01T00:00:00Z', + editedAt: '2024-01-01T00:00:00Z', } const b: Recipe = {...a, id: 2, title: 'Pesto Pasta'}