feat(backend): implement step 10 — JWT authentication (JwtService, AuthService, AuthController, JwtAuthFilter, SecurityConfig)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user