feat(backend): implement step 10 — JWT authentication (JwtService, AuthService, AuthController, JwtAuthFilter, SecurityConfig)

This commit is contained in:
2026-03-26 19:08:09 -03:00
parent 9065db504e
commit 031ad3d4b2
9 changed files with 243 additions and 138 deletions

View File

@@ -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)
}
}

View File

@@ -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.Bean
import org.springframework.context.annotation.Configuration 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.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 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.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 @Configuration
@EnableWebSecurity @EnableWebSecurity
class SecurityConfig { class SecurityConfig(private val jwtAuthFilter: JwtAuthFilter) {
@Bean @Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain { fun filterChain(http: HttpSecurity): SecurityFilterChain {
http http
.csrf { it.disable() } .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() return http.build()
} }
} }

View File

@@ -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<AuthResponse> {
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<AuthResponse> =
ResponseEntity.ok(AuthResponse("Authenticated"))
/** Clears the JWT cookie. */
@PostMapping("/logout")
fun logout(response: HttpServletResponse): ResponseEntity<AuthResponse> {
val cookie = Cookie("jwt", "").apply {
isHttpOnly = true
path = "/"
maxAge = 0
}
response.addCookie(cookie)
return ResponseEntity.ok(AuthResponse("Logged out"))
}
}

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -6,7 +6,9 @@ import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.DispatchLogRepository import com.condado.newsletter.repository.DispatchLogRepository
import com.condado.newsletter.repository.VirtualEntityRepository import com.condado.newsletter.repository.VirtualEntityRepository
import com.condado.newsletter.scheduler.EntityScheduler import com.condado.newsletter.scheduler.EntityScheduler
import com.condado.newsletter.service.JwtService
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import jakarta.servlet.http.Cookie
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -21,17 +23,13 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@AutoConfigureMockMvc @AutoConfigureMockMvc
class DispatchLogControllerTest { class DispatchLogControllerTest {
@Autowired @Autowired lateinit var mockMvc: MockMvc
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 private fun authCookie() = Cookie("jwt", jwtService.generateToken())
lateinit var virtualEntityRepository: VirtualEntityRepository
@Autowired
lateinit var dispatchLogRepository: DispatchLogRepository
@MockkBean
lateinit var entityScheduler: EntityScheduler
@AfterEach @AfterEach
fun cleanUp() { fun cleanUp() {
@@ -41,42 +39,19 @@ class DispatchLogControllerTest {
@Test @Test
fun should_return200AndAllLogs_when_getAllLogs() { fun should_return200AndAllLogs_when_getAllLogs() {
val entity = virtualEntityRepository.save(VirtualEntity( val entity = virtualEntityRepository.save(VirtualEntity(name = "Log Entity", email = "log@condado.com", jobTitle = "Logger"))
name = "Log Entity", dispatchLogRepository.save(DispatchLog(virtualEntity = entity, emailSubject = "Test Subject", status = DispatchStatus.SENT))
email = "log@condado.com", mockMvc.perform(get("/api/v1/dispatch-logs").cookie(authCookie()))
jobTitle = "Logger" .andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].emailSubject").value("Test Subject"))
))
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"))
} }
@Test @Test
fun should_return200AndFilteredLogs_when_getByEntityId() { fun should_return200AndFilteredLogs_when_getByEntityId() {
val entity1 = virtualEntityRepository.save(VirtualEntity( val entity1 = virtualEntityRepository.save(VirtualEntity(name = "Entity One", email = "one@condado.com", jobTitle = "Job One"))
name = "Entity One", val entity2 = virtualEntityRepository.save(VirtualEntity(name = "Entity Two", email = "two@condado.com", jobTitle = "Job Two"))
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 = entity1, emailSubject = "Log One", status = DispatchStatus.SENT))
dispatchLogRepository.save(DispatchLog(virtualEntity = entity2, emailSubject = "Log Two", status = DispatchStatus.FAILED)) dispatchLogRepository.save(DispatchLog(virtualEntity = entity2, emailSubject = "Log Two", status = DispatchStatus.FAILED))
mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}").cookie(authCookie()))
mockMvc.perform(get("/api/v1/dispatch-logs/entity/${entity1.id}")) .andExpect(status().isOk).andExpect(jsonPath("$.length()").value(1)).andExpect(jsonPath("$[0].emailSubject").value("Log One"))
.andExpect(status().isOk)
.andExpect(jsonPath("$").isArray)
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].emailSubject").value("Log One"))
} }
} }

View File

@@ -3,12 +3,14 @@ package com.condado.newsletter.controller
import com.condado.newsletter.model.VirtualEntity import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.VirtualEntityRepository import com.condado.newsletter.repository.VirtualEntityRepository
import com.condado.newsletter.scheduler.EntityScheduler import com.condado.newsletter.scheduler.EntityScheduler
import com.condado.newsletter.service.JwtService
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.runs import io.mockk.runs
import io.mockk.verify import io.mockk.verify
import jakarta.servlet.http.Cookie
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -27,135 +29,72 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@AutoConfigureMockMvc @AutoConfigureMockMvc
class VirtualEntityControllerTest { class VirtualEntityControllerTest {
@Autowired @Autowired lateinit var mockMvc: MockMvc
lateinit var mockMvc: MockMvc @Autowired lateinit var virtualEntityRepository: VirtualEntityRepository
@Autowired lateinit var jwtService: JwtService
@Autowired @MockkBean lateinit var entityScheduler: EntityScheduler
lateinit var virtualEntityRepository: VirtualEntityRepository
@MockkBean
lateinit var entityScheduler: EntityScheduler
private val objectMapper = ObjectMapper() private val objectMapper = ObjectMapper()
private fun authCookie() = Cookie("jwt", jwtService.generateToken())
@AfterEach @AfterEach
fun cleanUp() { fun cleanUp() { virtualEntityRepository.deleteAll() }
virtualEntityRepository.deleteAll()
}
@Test @Test
fun should_return201AndBody_when_postWithValidPayload() { fun should_return201AndBody_when_postWithValidPayload() {
val payload = mapOf( val payload = mapOf("name" to "Fulano da Silva", "email" to "fulano@condado.com", "jobTitle" to "Diretor de Nada")
"name" to "Fulano da Silva", mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload)))
"email" to "fulano@condado.com", .andExpect(status().isCreated).andExpect(jsonPath("$.name").value("Fulano da Silva")).andExpect(jsonPath("$.id").isNotEmpty)
"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)
} }
@Test @Test
fun should_return400_when_postWithMissingRequiredField() { fun should_return400_when_postWithMissingRequiredField() {
val payload = mapOf("name" to "Fulano") // missing email and jobTitle val payload = mapOf("name" to "Fulano")
mockMvc.perform(post("/api/v1/virtual-entities").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content(objectMapper.writeValueAsString(payload)))
mockMvc.perform(
post("/api/v1/virtual-entities")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload))
)
.andExpect(status().isBadRequest) .andExpect(status().isBadRequest)
} }
@Test @Test
fun should_return200AndList_when_getAllEntities() { fun should_return200AndList_when_getAllEntities() {
virtualEntityRepository.save(VirtualEntity( virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "test@condado.com", jobTitle = "Tester"))
name = "Test Entity", mockMvc.perform(get("/api/v1/virtual-entities").cookie(authCookie()))
email = "test@condado.com", .andExpect(status().isOk).andExpect(jsonPath("$").isArray).andExpect(jsonPath("$[0].name").value("Test Entity"))
jobTitle = "Tester"
))
mockMvc.perform(get("/api/v1/virtual-entities"))
.andExpect(status().isOk)
.andExpect(jsonPath("$").isArray)
.andExpect(jsonPath("$[0].name").value("Test Entity"))
} }
@Test @Test
fun should_return200AndEntity_when_getById() { fun should_return200AndEntity_when_getById() {
val entity = virtualEntityRepository.save(VirtualEntity( val entity = virtualEntityRepository.save(VirtualEntity(name = "Test Entity", email = "entity@condado.com", jobTitle = "Test Job"))
name = "Test Entity", mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()))
email = "entity@condado.com", .andExpect(status().isOk).andExpect(jsonPath("$.name").value("Test Entity"))
jobTitle = "Test Job"
))
mockMvc.perform(get("/api/v1/virtual-entities/${entity.id}"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.name").value("Test Entity"))
} }
@Test @Test
fun should_return404_when_getByIdNotFound() { fun should_return404_when_getByIdNotFound() {
val randomId = java.util.UUID.randomUUID() mockMvc.perform(get("/api/v1/virtual-entities/${java.util.UUID.randomUUID()}").cookie(authCookie()))
mockMvc.perform(get("/api/v1/virtual-entities/$randomId"))
.andExpect(status().isNotFound) .andExpect(status().isNotFound)
} }
@Test @Test
fun should_return200_when_putWithValidPayload() { fun should_return200_when_putWithValidPayload() {
val entity = virtualEntityRepository.save(VirtualEntity( val entity = virtualEntityRepository.save(VirtualEntity(name = "Old Name", email = "old@condado.com", jobTitle = "Old Job"))
name = "Old Name", mockMvc.perform(put("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()).contentType(MediaType.APPLICATION_JSON).content("""{"name":"New Name"}"""))
email = "old@condado.com", .andExpect(status().isOk).andExpect(jsonPath("$.name").value("New Name")).andExpect(jsonPath("$.email").value("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"))
} }
@Test @Test
fun should_return200AndDeactivated_when_delete() { fun should_return200AndDeactivated_when_delete() {
val entity = virtualEntityRepository.save(VirtualEntity( val entity = virtualEntityRepository.save(VirtualEntity(name = "Active Entity", email = "active@condado.com", jobTitle = "Active Job"))
name = "Active Entity", mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}").cookie(authCookie()))
email = "active@condado.com", .andExpect(status().isOk).andExpect(jsonPath("$.active").value(false))
jobTitle = "Active Job"
))
mockMvc.perform(delete("/api/v1/virtual-entities/${entity.id}"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.active").value(false))
} }
@Test @Test
fun should_return200_when_triggerEndpointCalled() { fun should_return200_when_triggerEndpointCalled() {
val entity = virtualEntityRepository.save(VirtualEntity( val entity = virtualEntityRepository.save(VirtualEntity(name = "Trigger Entity", email = "trigger@condado.com", jobTitle = "Trigger Job"))
name = "Trigger Entity",
email = "trigger@condado.com",
jobTitle = "Trigger Job"
))
every { entityScheduler.runPipeline(any()) } just runs every { entityScheduler.runPipeline(any()) } just runs
mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger").cookie(authCookie()))
mockMvc.perform(post("/api/v1/virtual-entities/${entity.id}/trigger"))
.andExpect(status().isOk) .andExpect(status().isOk)
verify(exactly = 1) { entityScheduler.runPipeline(any()) } verify(exactly = 1) { entityScheduler.runPipeline(any()) }
} }
} }