From 031ad3d4b2e5099e48524bbba61850a7c1e39528 Mon Sep 17 00:00:00 2001 From: Gabriel Sancho Date: Thu, 26 Mar 2026 19:08:09 -0300 Subject: [PATCH] =?UTF-8?q?feat(backend):=20implement=20step=2010=20?= =?UTF-8?q?=E2=80=94=20JWT=20authentication=20(JwtService,=20AuthService,?= =?UTF-8?q?=20AuthController,=20JwtAuthFilter,=20SecurityConfig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newsletter/config/JwtAuthFilter.kt | 40 ++++++ .../newsletter/config/SecurityConfig.kt | 24 ++-- .../newsletter/controller/AuthController.kt | 54 ++++++++ .../com/condado/newsletter/dto/AuthDtos.kt | 7 ++ .../condado/newsletter/service/AuthService.kt | 26 ++++ .../condado/newsletter/service/JwtService.kt | 46 +++++++ .../service/UnauthorizedException.kt | 8 ++ .../controller/DispatchLogControllerTest.kt | 57 +++------ .../controller/VirtualEntityControllerTest.kt | 119 +++++------------- 9 files changed, 243 insertions(+), 138 deletions(-) create mode 100644 backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt create mode 100644 backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt b/backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt new file mode 100644 index 0000000..4552013 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/config/JwtAuthFilter.kt @@ -0,0 +1,40 @@ +package com.condado.newsletter.config + +import com.condado.newsletter.service.JwtService +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +/** + * Reads the JWT from the `jwt` cookie on each request. + * If the token is valid, sets an authenticated [SecurityContext]. + */ +@Component +class JwtAuthFilter(private val jwtService: JwtService) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + val token = request.cookies + ?.firstOrNull { it.name == "jwt" } + ?.value + + if (token != null && jwtService.validateToken(token)) { + val auth = UsernamePasswordAuthenticationToken( + "admin", + null, + listOf(SimpleGrantedAuthority("ROLE_ADMIN")) + ) + SecurityContextHolder.getContext().authentication = auth + } + + filterChain.doFilter(request, response) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt b/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt index 9b46412..67d1b5d 100644 --- a/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/com/condado/newsletter/config/SecurityConfig.kt @@ -1,24 +1,34 @@ -package com.condado.newsletter.config +package com.condado.newsletter.config import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.HttpStatusEntryPoint +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter -/** - * Security configuration — Step 9: permits all requests. - * Will be updated in Step 10 with JWT authentication. - */ @Configuration @EnableWebSecurity -class SecurityConfig { +class SecurityConfig(private val jwtAuthFilter: JwtAuthFilter) { @Bean fun filterChain(http: HttpSecurity): SecurityFilterChain { http .csrf { it.disable() } - .authorizeHttpRequests { it.anyRequest().permitAll() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .exceptionHandling { it.authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) } + .authorizeHttpRequests { auth -> + auth + .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll() + .requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + } + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java) return http.build() } } diff --git a/backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt b/backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt new file mode 100644 index 0000000..872270d --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/controller/AuthController.kt @@ -0,0 +1,54 @@ +package com.condado.newsletter.controller + +import com.condado.newsletter.dto.AuthResponse +import com.condado.newsletter.dto.LoginRequest +import com.condado.newsletter.service.AuthService +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +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 + +/** + * Handles authentication — login, logout, and session check. + */ +@RestController +@RequestMapping("/api/auth") +class AuthController(private val authService: AuthService) { + + /** Validates the password and sets a JWT cookie on success. */ + @PostMapping("/login") + fun login( + @RequestBody request: LoginRequest, + response: HttpServletResponse + ): ResponseEntity { + val token = authService.login(request.password) + val cookie = Cookie("jwt", token).apply { + isHttpOnly = true + path = "/" + maxAge = 86400 // 24 hours + } + response.addCookie(cookie) + return ResponseEntity.ok(AuthResponse("Login successful")) + } + + /** Returns 200 if the JWT cookie is valid (checked by JwtAuthFilter). */ + @GetMapping("/me") + fun me(): ResponseEntity = + ResponseEntity.ok(AuthResponse("Authenticated")) + + /** Clears the JWT cookie. */ + @PostMapping("/logout") + fun logout(response: HttpServletResponse): ResponseEntity { + val cookie = Cookie("jwt", "").apply { + isHttpOnly = true + path = "/" + maxAge = 0 + } + response.addCookie(cookie) + return ResponseEntity.ok(AuthResponse("Logged out")) + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt b/backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt new file mode 100644 index 0000000..03e4dc5 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/dto/AuthDtos.kt @@ -0,0 +1,7 @@ +package com.condado.newsletter.dto + +/** Request body for POST /api/auth/login. */ +data class LoginRequest(val password: String) + +/** Generic response body for auth operations. */ +data class AuthResponse(val message: String) diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt new file mode 100644 index 0000000..fafb0be --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/AuthService.kt @@ -0,0 +1,26 @@ +package com.condado.newsletter.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +/** + * Handles the single-admin authentication flow. + * There is no user table — the password lives only in the [appPassword] environment variable. + */ +@Service +class AuthService( + private val jwtService: JwtService, + @Value("\${app.password}") private val appPassword: String +) { + /** + * Validates the given [password] against [appPassword]. + * @return A signed JWT token string. + * @throws [UnauthorizedException] if the password is incorrect. + */ + fun login(password: String): String { + if (password != appPassword) { + throw UnauthorizedException("Invalid password") + } + return jwtService.generateToken() + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt new file mode 100644 index 0000000..4266251 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/JwtService.kt @@ -0,0 +1,46 @@ +package com.condado.newsletter.service + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.Date + +/** + * Handles JWT token creation and validation using JJWT 0.12.x. + * The secret and expiration are read from environment variables. + */ +@Service +class JwtService( + @Value("\${app.jwt.secret}") val secret: String, + @Value("\${app.jwt.expiration-ms}") val expirationMs: Long +) { + private val signingKey by lazy { + Keys.hmacShaKeyFor(secret.toByteArray(Charsets.UTF_8)) + } + + /** Generates a new signed JWT token valid for [expirationMs] milliseconds. */ + fun generateToken(): String { + val now = Date() + return Jwts.builder() + .subject("admin") + .issuedAt(now) + .expiration(Date(now.time + expirationMs)) + .signWith(signingKey) + .compact() + } + + /** + * Validates a JWT token. + * @return `true` if the token is valid and not expired; `false` otherwise. + */ + fun validateToken(token: String): Boolean = try { + Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token) + true + } catch (e: ExpiredJwtException) { + false + } catch (e: Exception) { + false + } +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt b/backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt new file mode 100644 index 0000000..10a6f61 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/UnauthorizedException.kt @@ -0,0 +1,8 @@ +package com.condado.newsletter.service + +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.ResponseStatus + +/** Thrown when an authentication attempt fails (wrong password or missing token). */ +@ResponseStatus(HttpStatus.UNAUTHORIZED) +class UnauthorizedException(message: String) : RuntimeException(message) diff --git a/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt index 87548f2..5f6d196 100644 --- a/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt +++ b/backend/src/test/kotlin/com/condado/newsletter/controller/DispatchLogControllerTest.kt @@ -6,7 +6,9 @@ import com.condado.newsletter.model.VirtualEntity import com.condado.newsletter.repository.DispatchLogRepository import com.condado.newsletter.repository.VirtualEntityRepository import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.JwtService import com.ninjasquad.springmockk.MockkBean +import jakarta.servlet.http.Cookie import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -21,17 +23,13 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @AutoConfigureMockMvc class DispatchLogControllerTest { - @Autowired - lateinit var mockMvc: MockMvc + @Autowired lateinit var mockMvc: MockMvc + @Autowired lateinit var virtualEntityRepository: VirtualEntityRepository + @Autowired lateinit var dispatchLogRepository: DispatchLogRepository + @Autowired lateinit var jwtService: JwtService + @MockkBean lateinit var entityScheduler: EntityScheduler - @Autowired - lateinit var virtualEntityRepository: VirtualEntityRepository - - @Autowired - lateinit var dispatchLogRepository: DispatchLogRepository - - @MockkBean - lateinit var entityScheduler: EntityScheduler + private fun authCookie() = Cookie("jwt", jwtService.generateToken()) @AfterEach fun cleanUp() { @@ -41,42 +39,19 @@ class DispatchLogControllerTest { @Test fun should_return200AndAllLogs_when_getAllLogs() { - val entity = virtualEntityRepository.save(VirtualEntity( - name = "Log Entity", - email = "log@condado.com", - jobTitle = "Logger" - )) - dispatchLogRepository.save(DispatchLog( - virtualEntity = entity, - emailSubject = "Test Subject", - status = DispatchStatus.SENT - )) - - mockMvc.perform(get("/api/v1/dispatch-logs")) - .andExpect(status().isOk) - .andExpect(jsonPath("$").isArray) - .andExpect(jsonPath("$[0].emailSubject").value("Test Subject")) + val entity = virtualEntityRepository.save(VirtualEntity(name = "Log Entity", email = "log@condado.com", jobTitle = "Logger")) + dispatchLogRepository.save(DispatchLog(virtualEntity = entity, emailSubject = "Test Subject", status = DispatchStatus.SENT)) + mockMvc.perform(get("/api/v1/dispatch-logs").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].emailSubject").value("Test Subject")) } @Test fun should_return200AndFilteredLogs_when_getByEntityId() { - val entity1 = virtualEntityRepository.save(VirtualEntity( - name = "Entity One", - email = "one@condado.com", - jobTitle = "Job One" - )) - val entity2 = virtualEntityRepository.save(VirtualEntity( - name = "Entity Two", - email = "two@condado.com", - jobTitle = "Job Two" - )) + val entity1 = virtualEntityRepository.save(VirtualEntity(name = "Entity One", email = "one@condado.com", jobTitle = "Job One")) + val entity2 = virtualEntityRepository.save(VirtualEntity(name = "Entity Two", email = "two@condado.com", jobTitle = "Job Two")) dispatchLogRepository.save(DispatchLog(virtualEntity = entity1, emailSubject = "Log One", status = DispatchStatus.SENT)) dispatchLogRepository.save(DispatchLog(virtualEntity = entity2, emailSubject = "Log Two", status = DispatchStatus.FAILED)) - - mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$").isArray) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].emailSubject").value("Log One")) + mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].emailSubject").value("Log One")) } } diff --git a/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt b/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt index 9ec9aa8..ee5ed1b 100644 --- a/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt +++ b/backend/src/test/kotlin/com/condado/newsletter/controller/VirtualEntityControllerTest.kt @@ -3,12 +3,14 @@ package com.condado.newsletter.controller import com.condado.newsletter.model.VirtualEntity import com.condado.newsletter.repository.VirtualEntityRepository import com.condado.newsletter.scheduler.EntityScheduler +import com.condado.newsletter.service.JwtService import com.fasterxml.jackson.databind.ObjectMapper import com.ninjasquad.springmockk.MockkBean import io.mockk.every import io.mockk.just import io.mockk.runs import io.mockk.verify +import jakarta.servlet.http.Cookie import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -27,135 +29,72 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @AutoConfigureMockMvc class VirtualEntityControllerTest { - @Autowired - lateinit var mockMvc: MockMvc - - @Autowired - lateinit var virtualEntityRepository: VirtualEntityRepository - - @MockkBean - lateinit var entityScheduler: EntityScheduler + @Autowired lateinit var mockMvc: MockMvc + @Autowired lateinit var virtualEntityRepository: VirtualEntityRepository + @Autowired lateinit var jwtService: JwtService + @MockkBean lateinit var entityScheduler: EntityScheduler private val objectMapper = ObjectMapper() + private fun authCookie() = Cookie("jwt", jwtService.generateToken()) + @AfterEach - fun cleanUp() { - virtualEntityRepository.deleteAll() - } + fun cleanUp() { virtualEntityRepository.deleteAll() } @Test fun should_return201AndBody_when_postWithValidPayload() { - val payload = mapOf( - "name" to "Fulano da Silva", - "email" to "fulano@condado.com", - "jobTitle" to "Diretor de Nada" - ) - - mockMvc.perform( - post("/api/v1/virtual-entities") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(payload)) - ) - .andExpect(status().isCreated) - .andExpect(jsonPath("$.name").value("Fulano da Silva")) - .andExpect(jsonPath("$.email").value("fulano@condado.com")) - .andExpect(jsonPath("$.jobTitle").value("Diretor de Nada")) - .andExpect(jsonPath("$.id").isNotEmpty) + val payload = mapOf("name" to "Fulano da Silva", "email" to "fulano@condado.com", "jobTitle" to "Diretor de Nada") + mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload))) + .andExpect(status().isCreated).andExpect(jsonPath("$.name").value("Fulano da Silva")).andExpect(jsonPath("$.id").isNotEmpty) } @Test fun should_return400_when_postWithMissingRequiredField() { - val payload = mapOf("name" to "Fulano") // missing email and jobTitle - - mockMvc.perform( - post("/api/v1/virtual-entities") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(payload)) - ) + val payload = mapOf("name" to "Fulano") + mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload))) .andExpect(status().isBadRequest) } @Test fun should_return200AndList_when_getAllEntities() { - virtualEntityRepository.save(VirtualEntity( - name = "Test Entity", - email = "test@condado.com", - jobTitle = "Tester" - )) - - mockMvc.perform(get("/api/v1/virtual-entities")) - .andExpect(status().isOk) - .andExpect(jsonPath("$").isArray) - .andExpect(jsonPath("$[0].name").value("Test Entity")) + virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "test@condado.com", jobTitle = "Tester")) + mockMvc.perform(get("/api/v1/virtual-entities").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].name").value("Test Entity")) } @Test fun should_return200AndEntity_when_getById() { - val entity = virtualEntityRepository.save(VirtualEntity( - name = "Test Entity", - email = "entity@condado.com", - jobTitle = "Test Job" - )) - - mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.name").value("Test Entity")) + val entity = virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "entity@condado.com", jobTitle = "Test Job")) + mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$.name").value("Test Entity")) } @Test fun should_return404_when_getByIdNotFound() { - val randomId = java.util.UUID.randomUUID() - - mockMvc.perform(get("/api/v1/virtual-entities/$randomId")) + mockMvc.perform(get("/api/v1/virtual-entities/${java.util.UUID.randomUUID()}").cookie(authCookie())) .andExpect(status().isNotFound) } @Test fun should_return200_when_putWithValidPayload() { - val entity = virtualEntityRepository.save(VirtualEntity( - name = "Old Name", - email = "old@condado.com", - jobTitle = "Old Job" - )) - - val payload = mapOf("name" to "New Name") - - mockMvc.perform( - put("/api/v1/virtual-entities/${entity.id}") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(payload)) - ) - .andExpect(status().isOk) - .andExpect(jsonPath("$.name").value("New Name")) - .andExpect(jsonPath("$.email").value("old@condado.com")) + val entity = virtualEntityRepository.save(VirtualEntity(name = "Old Name", email = "old@condado.com", jobTitle = "Old Job")) + mockMvc.perform(put("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content("""{"name":"New Name"}""")) + .andExpect(status().isOk).andExpect(jsonPath("$.name").value("New Name")).andExpect(jsonPath("$.email").value("old@condado.com")) } @Test fun should_return200AndDeactivated_when_delete() { - val entity = virtualEntityRepository.save(VirtualEntity( - name = "Active Entity", - email = "active@condado.com", - jobTitle = "Active Job" - )) - - mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.active").value(false)) + val entity = virtualEntityRepository.save(VirtualEntity(name = "Active Entity", email = "active@condado.com", jobTitle = "Active Job")) + mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}").cookie(authCookie())) + .andExpect(status().isOk).andExpect(jsonPath("$.active").value(false)) } @Test fun should_return200_when_triggerEndpointCalled() { - val entity = virtualEntityRepository.save(VirtualEntity( - name = "Trigger Entity", - email = "trigger@condado.com", - jobTitle = "Trigger Job" - )) - + val entity = virtualEntityRepository.save(VirtualEntity(name = "Trigger Entity", email = "trigger@condado.com", jobTitle = "Trigger Job")) every { entityScheduler.runPipeline(any()) } just runs - - mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger")) + mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger").cookie(authCookie())) .andExpect(status().isOk) - verify(exactly = 1) { entityScheduler.runPipeline(any()) } } }