From e82aa047035488ed16d1f9ecb17f10c4deb2832c Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Sat, 8 Feb 2025 21:27:12 +0900 Subject: [PATCH 1/7] feat: Add controller for user auth --- backend/build.gradle.kts | 1 + .../lightswitch/controller/UserController.kt | 57 +++++++++ .../controller/request/UserAccount.kt | 8 ++ .../controller/UserControllerTest.kt | 108 ++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 backend/src/main/kotlin/com/lightswitch/controller/UserController.kt create mode 100644 backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt create mode 100644 backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index d1bcae6..a4a0fbb 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("org.springframework.boot:spring-boot-starter-validation") // lombok compileOnly("org.projectlombok:lombok") diff --git a/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt b/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt new file mode 100644 index 0000000..a578f59 --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt @@ -0,0 +1,57 @@ +package com.lightswitch.controller + +import com.lightswitch.controller.request.UserAccount +import com.lightswitch.exception.BusinessException +import com.lightswitch.security.AuthService +import com.lightswitch.security.jwt.JwtToken +import jakarta.validation.Valid +import jakarta.validation.constraints.NotEmpty +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/users") +class UserController(private val authService: AuthService) { + + @PostMapping("/login") + + fun userLogin(@RequestBody @Valid body: UserAccount): ResponseEntity { + val token = authService.login(body.username, body.password) + return ResponseEntity.ok(token) + } + + @PostMapping("/initialize") + fun userInitialize(@RequestBody @Valid body: UserAccount): ResponseEntity { + return try { + val user = authService.signup(body.username, body.password) + ResponseEntity.ok("SignUp Completed: ${user.username}") + } catch (e: BusinessException) { + ResponseEntity.badRequest().body(e.message) + } + } + + @PutMapping("/auth/refresh") + fun refreshUserToken(@RequestHeader("Authorization") @NotEmpty refreshToken: String): ResponseEntity { + val authentication: Authentication = SecurityContextHolder.getContext().authentication + val token = + authService.reissue(refreshToken.removePrefix("Bearer "), authentication.name.toLong()) + return ResponseEntity.ok(token) + } + + @PostMapping("/logout") + fun userLogout( + @RequestHeader("Authorization") @NotEmpty accessToken: String, + @RequestBody @Valid body: UserAccount + ): ResponseEntity { + return try { + val authentication: Authentication = SecurityContextHolder.getContext().authentication + val userId = authentication.name + authService.logout(userId.toLong()) + ResponseEntity.ok("Log out Completed: ${body.username}") + } catch (e: IllegalArgumentException) { + ResponseEntity.badRequest().body(e.message) + } + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt b/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt new file mode 100644 index 0000000..e9f3191 --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt @@ -0,0 +1,8 @@ +package com.lightswitch.controller.request + +import jakarta.validation.constraints.NotNull + +data class UserAccount( + @NotNull val username: String, + @NotNull val password: String +) \ No newline at end of file diff --git a/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt b/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt new file mode 100644 index 0000000..b616da9 --- /dev/null +++ b/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt @@ -0,0 +1,108 @@ +package com.lightswitch.controller + +import com.lightswitch.controller.request.UserAccount +import com.lightswitch.exception.BusinessException +import com.lightswitch.security.AuthService +import com.lightswitch.security.jwt.JwtToken +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.MediaType +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import org.springframework.security.core.context.SecurityContext +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.test.web.servlet.setup.MockMvcBuilders + +@ExtendWith(MockitoExtension::class) +class UserControllerTest { + + @Autowired + lateinit var mockMvc: MockMvc + + @Mock + lateinit var authService: AuthService + + @InjectMocks + lateinit var userController: UserController + + @BeforeEach + fun setup() { + mockMvc = MockMvcBuilders.standaloneSetup(userController).build() + val authentication: Authentication = UsernamePasswordAuthenticationToken("1", "password", emptyList()) + val securityContext: SecurityContext = SecurityContextHolder.createEmptyContext() + securityContext.authentication = authentication + SecurityContextHolder.setContext(securityContext) + } + + @Test + fun `should login user successfully`() { + val jwtToken = JwtToken("accessToken", "refreshToken") + + `when`(authService.login("testUser", "password")).thenReturn(jwtToken) + + mockMvc.perform( + MockMvcRequestBuilders.post("/api/v1/users/login") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"username": "testUser", "password": "password"}""") + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.jsonPath("$.accessToken").value("accessToken")) + .andExpect(MockMvcResultMatchers.jsonPath("$.refreshToken").value("refreshToken")) + } + + @Test + fun `should return bad request when signup fails`() { + `when`( + authService.signup( + "testUser", + "password" + ) + ).thenThrow(BusinessException("User already exists")) + + mockMvc.perform( + MockMvcRequestBuilders.post("/api/v1/users/initialize") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"username": "testUser", "password": "password"}""") + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + .andExpect(MockMvcResultMatchers.content().string("User already exists")) + } + + @Test + fun `should refresh user token successfully`() { + val refreshToken = "Bearer refreshToken" + val jwtToken = JwtToken("newAccessToken", "newRefreshToken") + + `when`(authService.reissue("refreshToken", 1L)).thenReturn(jwtToken) + + mockMvc.perform( + MockMvcRequestBuilders.put("/api/v1/users/auth/refresh") + .header("Authorization", refreshToken) + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.jsonPath("$.accessToken").value("newAccessToken")) + .andExpect(MockMvcResultMatchers.jsonPath("$.refreshToken").value("newRefreshToken")) + } + + @Test + fun `should logout user successfully`() { + mockMvc.perform( + MockMvcRequestBuilders.post("/api/v1/users/logout") + .header("Authorization", "Bearer someAccessToken") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"username": "testUser", "password": "password"}""") + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andExpect(MockMvcResultMatchers.content().string("Log out Completed: testUser")) + } +} From 6ecb38d6601aac400dc3f0637564661f380cd4fe Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Sat, 8 Feb 2025 21:27:58 +0900 Subject: [PATCH 2/7] feat: Add service for user auth --- .../com/lightswitch/security/AuthService.kt | 26 ++--- .../security/jwt/JwtTokenProvider.kt | 6 +- .../lightswitch/security/AuthServiceTest.kt | 100 +++++++++--------- .../security/jwt/JwtTokenProviderTest.kt | 10 +- 4 files changed, 73 insertions(+), 69 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/security/AuthService.kt b/backend/src/main/kotlin/com/lightswitch/security/AuthService.kt index 4b29dff..dddf440 100644 --- a/backend/src/main/kotlin/com/lightswitch/security/AuthService.kt +++ b/backend/src/main/kotlin/com/lightswitch/security/AuthService.kt @@ -32,7 +32,10 @@ class AuthService( user.lastLoginAt = LocalDateTime.now() userRepository.save(user) - return jwtTokenProvider.generateJwtToken(user.id!!, user) + val jwtToken = jwtTokenProvider.generateJwtToken(user.id!!, user) + refreshTokenRepository.save(RefreshToken(user.id!!, jwtToken.refreshToken!!)) + + return jwtToken } @Transactional @@ -50,20 +53,14 @@ class AuthService( } @Transactional - fun reissue(jwtToken: JwtToken): JwtToken? { - if (!jwtTokenProvider.validateToken(jwtToken.refreshToken!!)) { - throw BusinessException("Refresh Token is Not Valid") - } - - val userId: Long = jwtTokenProvider.getRefreshTokenSubject(jwtToken.refreshToken) + fun reissue(jwtToken: String, userId: Long): JwtToken? { val refreshToken: RefreshToken = refreshTokenRepository.findById(userId) .orElseThrow { BusinessException("Log-out user") } - if (refreshToken.value != jwtToken.refreshToken) { + if (refreshToken.value != jwtToken) { throw BusinessException("Refresh Token is Not Valid") } - val newToken: JwtToken? val user = userRepository.findById(userId) .orElseThrow { BusinessException("User not found") } @@ -71,11 +68,16 @@ class AuthService( jwtTokenProvider.isRefreshTokenRenewalRequired(refreshToken.value) -> { jwtTokenProvider.generateJwtToken(userId, user).also { refreshToken.value = it.refreshToken.toString() - refreshTokenRepository.save(refreshToken) } } - - else -> jwtTokenProvider.generateJwtAccessToken(userId, user, Date()) + else -> { + jwtTokenProvider.generateJwtAccessToken(userId, user, Date(), refreshToken.value) + } } } + + @Transactional + fun logout(userId: Long) { + refreshTokenRepository.deleteById(userId) + } } \ No newline at end of file diff --git a/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenProvider.kt b/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenProvider.kt index 6893a4b..227fa0d 100644 --- a/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenProvider.kt +++ b/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenProvider.kt @@ -81,11 +81,11 @@ class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)) } - fun generateJwtAccessToken(userId: Long, user: User, now: Date): JwtToken { + fun generateJwtAccessToken(userId: Long, user: User, now: Date, refreshToken: String): JwtToken { val accessToken = createAccessToken(userId, user, now) return JwtToken( accessToken = accessToken, - refreshToken = null, + refreshToken = refreshToken, accessTokenExpiredDate = accessValidTime ) } @@ -149,7 +149,7 @@ class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { val now = (Date()).time val refreshExpiredTime = claimsJws.body.expiration.time - return refreshExpiredTime - now > threeDays + return refreshExpiredTime - now <= threeDays } } \ No newline at end of file diff --git a/backend/src/test/kotlin/com/lightswitch/security/AuthServiceTest.kt b/backend/src/test/kotlin/com/lightswitch/security/AuthServiceTest.kt index 143a3c5..b141f82 100644 --- a/backend/src/test/kotlin/com/lightswitch/security/AuthServiceTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/security/AuthServiceTest.kt @@ -11,11 +11,13 @@ import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers -import org.mockito.kotlin.any -import org.mockito.kotlin.eq import org.mockito.Mock -import org.mockito.Mockito +import org.mockito.Mockito.* import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.verify +import org.springframework.security.core.Authentication import org.springframework.security.crypto.password.PasswordEncoder import java.time.LocalDateTime import java.util.* @@ -65,13 +67,13 @@ class AuthServiceTest { val password = "correctPassword" val jwtToken = JwtToken("accessToken", "refreshToken", 1800) - Mockito.`when`(userRepository.findByUsername(username)) + `when`(userRepository.findByUsername(username)) .thenReturn(user) - Mockito.`when`(passwordEncoder.matches(password, user.passwordHash)) + `when`(passwordEncoder.matches(password, user.passwordHash)) .thenReturn(true) - Mockito.`when`(jwtTokenProvider.generateJwtToken(user.id!!, user)) + `when`(jwtTokenProvider.generateJwtToken(user.id!!, user)) .thenReturn(jwtToken) val result = authService.login(username, password) @@ -79,6 +81,8 @@ class AuthServiceTest { assertNotNull(result) assertEquals(jwtToken.accessToken, result.accessToken) assertEquals(jwtToken.refreshToken, result.refreshToken) + verify(userRepository).save(user) + verify(refreshTokenRepository).save(any()) } @Test @@ -86,7 +90,7 @@ class AuthServiceTest { val username = "invalidUser" val password = "somePassword" - Mockito.`when`(userRepository.findByUsername(username)) + `when`(userRepository.findByUsername(username)) .thenReturn(null) val exception = assertThrows(BusinessException::class.java) { @@ -100,10 +104,10 @@ class AuthServiceTest { val username = "testUser" val password = "incorrectPassword" - Mockito.`when`(userRepository.findByUsername(username)) + `when`(userRepository.findByUsername(username)) .thenReturn(user) - Mockito.`when`(passwordEncoder.matches(password, user.passwordHash)) + `when`(passwordEncoder.matches(password, user.passwordHash)) .thenReturn(false) val exception = assertThrows(BusinessException::class.java) { @@ -122,19 +126,20 @@ class AuthServiceTest { passwordHash = passwordHash ) - Mockito.`when`(userRepository.existsByUsername(username)) + `when`(userRepository.existsByUsername(username)) .thenReturn(false) - Mockito.`when`(passwordEncoder.encode(password)) + `when`(passwordEncoder.encode(password)) .thenReturn(passwordHash) - Mockito.`when`(userRepository.save(ArgumentMatchers.any(User::class.java))) + `when`(userRepository.save(ArgumentMatchers.any(User::class.java))) .thenReturn(newUser) val result = authService.signup(username, password) assertNotNull(result) assertEquals(username, result.username) + verify(userRepository).save(any(User::class.java)) } @Test @@ -142,7 +147,7 @@ class AuthServiceTest { val username = "existingUser" val password = "somePassword" - Mockito.`when`(userRepository.existsByUsername(username)) + `when`(userRepository.existsByUsername(username)) .thenReturn(true) val exception = assertThrows(BusinessException::class.java) { @@ -156,80 +161,77 @@ class AuthServiceTest { val userId = 1L val token = JwtToken("accessToken", "refreshToken", 1800) val newToken = JwtToken("newAccessToken", "newRefreshToken", 1800) - val refreshTokenValue = "refreshToken" - - Mockito.`when`(jwtTokenProvider.validateToken(refreshTokenValue)) - .thenReturn(true) - - Mockito.`when`(jwtTokenProvider.getRefreshTokenSubject(refreshTokenValue)) - .thenReturn(userId) - Mockito.`when`(refreshTokenRepository.findById(userId)) + `when`(refreshTokenRepository.findById(userId)) .thenReturn(Optional.of(this.refreshToken)) - Mockito.`when`(userRepository.findById(userId)) + `when`(userRepository.findById(userId)) .thenReturn(Optional.of(user)) - Mockito.`when`(jwtTokenProvider.isRefreshTokenRenewalRequired(token.refreshToken)) + `when`(jwtTokenProvider.isRefreshTokenRenewalRequired(token.refreshToken)) .thenReturn(true) - Mockito.`when`(jwtTokenProvider.generateJwtToken(userId, user)) + `when`(jwtTokenProvider.generateJwtToken(userId, user)) .thenReturn(newToken) - val result = authService.reissue(token) + val result = authService.reissue(token.refreshToken.toString(), userId) assertNotNull(result) assertEquals(newToken.accessToken, result?.accessToken) assertEquals(newToken.refreshToken, result?.refreshToken) } - @Test - fun `reissue should throw BusinessException if refresh token is invalid`() { - val invalidRefreshToken = JwtToken("", "invalidToken", 1800) - - Mockito.`when`(jwtTokenProvider.validateToken(invalidRefreshToken.refreshToken!!)) - .thenReturn(false) - - val exception = assertThrows(BusinessException::class.java) { - authService.reissue(invalidRefreshToken) - } - assertEquals("Refresh Token is Not Valid", exception.message) - } @Test fun `reissue should return new JwtToken if refresh token is valid but no renewal is required`() { val userId = 1L val token = JwtToken("accessToken", "refreshToken", 1800) - val newToken = JwtToken("newAccessToken", null, 1800) + val newToken = JwtToken("newAccessToken", "refreshToken", 1800) - Mockito.`when`(jwtTokenProvider.validateToken(token.refreshToken!!)) + `when`(jwtTokenProvider.validateToken(token.refreshToken!!)) .thenReturn(true) - Mockito.`when`(jwtTokenProvider.getRefreshTokenSubject(token.refreshToken!!)) + `when`(jwtTokenProvider.getRefreshTokenSubject(token.refreshToken!!)) .thenReturn(userId) - Mockito.`when`(refreshTokenRepository.findById(userId)) + `when`(refreshTokenRepository.findById(userId)) .thenReturn(Optional.of(this.refreshToken)) - Mockito.`when`(userRepository.findById(userId)) + `when`(userRepository.findById(userId)) .thenReturn(Optional.of(user)) - Mockito.`when`(jwtTokenProvider.isRefreshTokenRenewalRequired(token.refreshToken)) + `when`(jwtTokenProvider.isRefreshTokenRenewalRequired(token.refreshToken)) .thenReturn(false) - Mockito.`when`( + `when`( jwtTokenProvider.generateJwtAccessToken( eq(userId), eq(user), - any() + any(), + eq( + token.refreshToken.toString() + ) ) - ) - .thenReturn(newToken) + ).thenReturn(newToken) - val result = authService.reissue(token) + val result = authService.reissue(token.refreshToken.toString(), userId) assertNotNull(result) assertEquals(newToken.accessToken, result?.accessToken) - assertNull(result?.refreshToken) + } + + @Test + fun `logout success test`() { + val accessToken = "validToken" + val authentication = mock(Authentication::class.java) + `when`(jwtTokenProvider.getAuthentication(accessToken)).thenReturn(authentication) + `when`(authentication.name).thenReturn("1") + + authService.logout(1L) + + verify( + refreshTokenRepository, + times(1) + ).deleteById(1L) } } diff --git a/backend/src/test/kotlin/com/lightswitch/security/jwt/JwtTokenProviderTest.kt b/backend/src/test/kotlin/com/lightswitch/security/jwt/JwtTokenProviderTest.kt index 061b8b0..be99075 100644 --- a/backend/src/test/kotlin/com/lightswitch/security/jwt/JwtTokenProviderTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/security/jwt/JwtTokenProviderTest.kt @@ -41,10 +41,10 @@ class JwtTokenProviderTest { @Test fun `generateJwtAccessToken should return only access token`() { val now = Date() - val jwtToken = jwtTokenProvider.generateJwtAccessToken(user.id!!, user, now) + val jwtToken = jwtTokenProvider.generateJwtAccessToken(user.id!!, user, now, "testRefreshToken") assertNotNull(jwtToken.accessToken) - assertNull(jwtToken.refreshToken) + assertEquals("testRefreshToken",jwtToken.refreshToken) assertTrue(jwtToken.accessTokenExpiredDate!! > 0) } @@ -100,11 +100,11 @@ class JwtTokenProviderTest { val result = jwtTokenProvider.isRefreshTokenRenewalRequired(refreshToken) - assertTrue(result) + assertFalse(result) } @Test - fun `refreshTokenPeriodCheck should return false if refresh token expired less than 3 days`() { + fun `refreshTokenPeriodCheck should return true if refresh token expired less than 3 days`() { val now = Date() val issuedTime = Date(now.time - 5 * 24 * 60 * 60 * 1000L) val expiredTime = Date(now.time + 2 * 24 * 60 * 60 * 1000L) @@ -118,7 +118,7 @@ class JwtTokenProviderTest { val result = jwtTokenProvider.isRefreshTokenRenewalRequired(refreshToken) - assertFalse(result) + assertTrue(result) } } From 96687aba4cb24b4b7f6a65880e20ccaa8749475d Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Sat, 8 Feb 2025 21:28:16 +0900 Subject: [PATCH 3/7] feat: Modify required settings --- .../com/lightswitch/security/config/SecurityConfig.kt | 10 ++++++++-- .../com/lightswitch/security/jwt/JwtTokenFilter.kt | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/security/config/SecurityConfig.kt b/backend/src/main/kotlin/com/lightswitch/security/config/SecurityConfig.kt index 56fad2f..6805dec 100644 --- a/backend/src/main/kotlin/com/lightswitch/security/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/com/lightswitch/security/config/SecurityConfig.kt @@ -33,11 +33,14 @@ class SecurityConfig( fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { http + .csrf { + it.disable() + } .authorizeHttpRequests { auth -> auth .dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.REQUEST) .permitAll() - .requestMatchers("/login/**").permitAll() + .requestMatchers("/api/v1/users/**").permitAll() .anyRequest().authenticated() } @@ -48,7 +51,10 @@ class SecurityConfig( .formLogin { form -> form.disable() } - .addFilterBefore(JwtTokenFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter::class.java) + .addFilterBefore( + JwtTokenFilter(jwtTokenProvider), + UsernamePasswordAuthenticationFilter::class.java + ) return http.build() } diff --git a/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenFilter.kt b/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenFilter.kt index 7313651..0dabef5 100644 --- a/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenFilter.kt +++ b/backend/src/main/kotlin/com/lightswitch/security/jwt/JwtTokenFilter.kt @@ -10,7 +10,9 @@ import org.springframework.web.filter.OncePerRequestFilter import java.io.IOException @Component -class JwtTokenFilter(private val jwtTokenProvider: JwtTokenProvider) : OncePerRequestFilter() { +class JwtTokenFilter( + private val jwtTokenProvider: JwtTokenProvider, +) : OncePerRequestFilter() { private val HEADER_STRING = "Authorization" private val TOKEN_PREFIX = "Bearer " From c6d647d7326461d6ef7987a01c28f127d3fd0335 Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Tue, 11 Feb 2025 20:45:49 +0900 Subject: [PATCH 4/7] feat: Modify to use companion object --- .../infrastructure/security/JwtTokenFilter.kt | 6 +++-- .../security/JwtTokenProvider.kt | 24 ++++++++++--------- .../security/JwtTokenFilterTest.kt | 7 +++--- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenFilter.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenFilter.kt index 8a7ee52..b79d2d4 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenFilter.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenFilter.kt @@ -14,8 +14,10 @@ class JwtTokenFilter( private val jwtTokenProvider: JwtTokenProvider, ) : OncePerRequestFilter() { - private val HEADER_STRING = "Authorization" - private val TOKEN_PREFIX = "Bearer " + companion object { + const val HEADER_STRING = "Authorization" + const val TOKEN_PREFIX = "Bearer " + } @Throws(ServletException::class, IOException::class) public override fun doFilterInternal( diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenProvider.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenProvider.kt index f1a5a75..cd09f95 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenProvider.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtTokenProvider.kt @@ -18,14 +18,16 @@ import javax.crypto.SecretKey class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { private val logger: Logger = LoggerFactory.getLogger(JwtTokenProvider::class.java) - private val TYPE = "tokenType" - private val USER = "user" - private val accessValidTime = 30 * 60 * 1000L // 30 minutes - private val refreshValidTime = 7 * 24 * 60 * 60 * 1000L // 7 days - private val threeDays = 3 * 24 * 60 * 60 * 1000L // 3 days - fun generateJwtToken(userId: Long, user: User): JwtToken { + companion object { + const val TYPE = "tokenType" + const val USER = "user" + const val ACCESS_VALID_TIME = 30 * 60 * 1000L // 30 minutes + const val REFRESH_VALID_TIME = 7 * 24 * 60 * 60 * 1000L // 7 days + const val THREE_DAYS = 3 * 24 * 60 * 60 * 1000L // 3 days + } + fun generateJwtToken(userId: Long, user: User): JwtToken { val now = Date() val accessToken = createAccessToken(userId, user, now) @@ -35,14 +37,14 @@ class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { val refreshToken = Jwts.builder() .setClaims(refreshTokenClaims) .setIssuedAt(now) - .setExpiration(Date(now.time + refreshValidTime)) + .setExpiration(Date(now.time + REFRESH_VALID_TIME)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact() return JwtToken( accessToken = accessToken, refreshToken = refreshToken, - accessTokenExpiredDate = accessValidTime + accessTokenExpiredDate = ACCESS_VALID_TIME ) } @@ -59,7 +61,7 @@ class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { val accessToken = Jwts.builder() .setClaims(accessTokenClaims) .setIssuedAt(now) - .setExpiration(Date(now.time + accessValidTime)) + .setExpiration(Date(now.time + ACCESS_VALID_TIME)) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact() return accessToken @@ -86,7 +88,7 @@ class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { return JwtToken( accessToken = accessToken, refreshToken = refreshToken, - accessTokenExpiredDate = accessValidTime + accessTokenExpiredDate = ACCESS_VALID_TIME ) } @@ -149,7 +151,7 @@ class JwtTokenProvider(@Value("\${jwt.secret}") private var secretKey: String) { val now = (Date()).time val refreshExpiredTime = claimsJws.body.expiration.time - return refreshExpiredTime - now <= threeDays + return refreshExpiredTime - now <= THREE_DAYS } } \ No newline at end of file diff --git a/backend/src/test/kotlin/com/lightswitch/infrastructue/security/JwtTokenFilterTest.kt b/backend/src/test/kotlin/com/lightswitch/infrastructue/security/JwtTokenFilterTest.kt index 56e3073..ea1c4e8 100644 --- a/backend/src/test/kotlin/com/lightswitch/infrastructue/security/JwtTokenFilterTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/infrastructue/security/JwtTokenFilterTest.kt @@ -5,6 +5,7 @@ import com.lightswitch.infrastructure.security.JwtTokenProvider import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mock @@ -47,7 +48,7 @@ class JwtTokenFilterTest { jwtTokenFilter.doFilterInternal(request, response, filterChain) val context: SecurityContext = SecurityContextHolder.getContext() - assert(context.authentication == authentication) + assertThat(context.authentication == authentication) Mockito.verify(filterChain).doFilter(request, response) } @@ -60,7 +61,7 @@ class JwtTokenFilterTest { jwtTokenFilter.doFilterInternal(request, response, filterChain) val context: SecurityContext = SecurityContextHolder.getContext() - assert(context.authentication == null) + assertThat(context.authentication == null) Mockito.verify(filterChain).doFilter(request, response) } @@ -71,7 +72,7 @@ class JwtTokenFilterTest { jwtTokenFilter.doFilterInternal(request, response, filterChain) val context: SecurityContext = SecurityContextHolder.getContext() - assert(context.authentication == null) + assertThat(context.authentication == null) Mockito.verify(filterChain).doFilter(request, response) } } From 5450bc74292f2601b64ceea0195cfa53dac19250 Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Tue, 11 Feb 2025 20:46:18 +0900 Subject: [PATCH 5/7] docs: Add openapi spec --- .../lightswitch/controller/UserController.kt | 30 ++++++++++++++----- .../controller/request/UserAccount.kt | 6 ++-- .../infrastructure/security/JwtToken.kt | 6 ++++ .../controller/UserControllerTest.kt | 7 ++--- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt b/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt index a578f59..64b3f70 100644 --- a/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt +++ b/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt @@ -1,9 +1,11 @@ package com.lightswitch.controller +import com.lightswitch.application.service.AuthService import com.lightswitch.controller.request.UserAccount -import com.lightswitch.exception.BusinessException -import com.lightswitch.security.AuthService -import com.lightswitch.security.jwt.JwtToken +import com.lightswitch.infrastructure.security.JwtToken +import com.lightswitch.presentation.exception.BusinessException +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter import jakarta.validation.Valid import jakarta.validation.constraints.NotEmpty import org.springframework.http.ResponseEntity @@ -14,14 +16,20 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/api/v1/users") class UserController(private val authService: AuthService) { - + @Operation( + summary = "Login the user", + description = "Authenticates a user with their username and password, and returns a JWT token." + ) @PostMapping("/login") - fun userLogin(@RequestBody @Valid body: UserAccount): ResponseEntity { val token = authService.login(body.username, body.password) return ResponseEntity.ok(token) } + @Operation( + summary = "Initialize user account", + description = "Registers a new user with the provided username and password. Returns a confirmation message." + ) @PostMapping("/initialize") fun userInitialize(@RequestBody @Valid body: UserAccount): ResponseEntity { return try { @@ -32,17 +40,25 @@ class UserController(private val authService: AuthService) { } } + @Operation( + summary = "Refresh authentication token", + description = "Reissues a new JWT token using the provided refresh token and current user's identity." + ) @PutMapping("/auth/refresh") - fun refreshUserToken(@RequestHeader("Authorization") @NotEmpty refreshToken: String): ResponseEntity { + fun refreshUserToken(@RequestHeader("Authorization") @NotEmpty @Parameter(description = "The refresh token prefixed with 'Bearer '.") refreshToken: String): ResponseEntity { val authentication: Authentication = SecurityContextHolder.getContext().authentication val token = authService.reissue(refreshToken.removePrefix("Bearer "), authentication.name.toLong()) return ResponseEntity.ok(token) } + @Operation( + summary = "Logout the user", + description = "Logs out the user by invalidating their access token. Requires the user to provide their token." + ) @PostMapping("/logout") fun userLogout( - @RequestHeader("Authorization") @NotEmpty accessToken: String, + @RequestHeader("Authorization") @NotEmpty @Parameter(description = "The access token prefixed with 'Bearer '.") accessToken: String, @RequestBody @Valid body: UserAccount ): ResponseEntity { return try { diff --git a/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt b/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt index e9f3191..a8a64e5 100644 --- a/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt +++ b/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt @@ -1,8 +1,10 @@ package com.lightswitch.controller.request +import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.NotNull +@Schema(description = "Represents a user's account credentials including username and password.") data class UserAccount( - @NotNull val username: String, - @NotNull val password: String + @NotNull @Schema(description = "The username of the user.") val username: String, + @NotNull @Schema(description = "The password of the user.")val password: String ) \ No newline at end of file diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtToken.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtToken.kt index 9400071..22f65af 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtToken.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/security/JwtToken.kt @@ -1,7 +1,13 @@ package com.lightswitch.infrastructure.security +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "Represents the JWT token response containing both access and refresh tokens, as well as the expiration date of the access token.") data class JwtToken( + @Schema(description = "The access token used to authenticate the user in subsequent requests. This token is valid for 30 minutes.") val accessToken: String? = null, + @Schema(description = "The refresh token used to obtain a new access token once it expires. This token is valid for 7 days.") val refreshToken: String? = null, + @Schema(description = "The expiration date (timestamp in milliseconds) of the access token. The token becomes invalid after this time.") val accessTokenExpiredDate: Long? = null ) diff --git a/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt b/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt index b616da9..2e337b9 100644 --- a/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt @@ -1,9 +1,8 @@ package com.lightswitch.controller -import com.lightswitch.controller.request.UserAccount -import com.lightswitch.exception.BusinessException -import com.lightswitch.security.AuthService -import com.lightswitch.security.jwt.JwtToken +import com.lightswitch.application.service.AuthService +import com.lightswitch.infrastructure.security.JwtToken +import com.lightswitch.presentation.exception.BusinessException import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith From eae2d5f370d752c2c946fbcb980711d58c5ec778 Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Tue, 11 Feb 2025 21:00:20 +0900 Subject: [PATCH 6/7] feat: Modify request body validation --- .../com/lightswitch/controller/request/UserAccount.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt b/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt index a8a64e5..60f18fd 100644 --- a/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt +++ b/backend/src/main/kotlin/com/lightswitch/controller/request/UserAccount.kt @@ -1,10 +1,14 @@ package com.lightswitch.controller.request import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.NotBlank @Schema(description = "Represents a user's account credentials including username and password.") data class UserAccount( - @NotNull @Schema(description = "The username of the user.") val username: String, - @NotNull @Schema(description = "The password of the user.")val password: String + @Schema(description = "The username of the user.") + @field:NotBlank(message = "Username is required.") + val username: String, + @Schema(description = "The password of the user.") + @field:NotBlank(message = "Password is required.") + val password: String ) \ No newline at end of file From 85077826da362d0923934037c430885a49ffb073 Mon Sep 17 00:00:00 2001 From: eunhwa99 Date: Tue, 18 Feb 2025 20:44:31 +0900 Subject: [PATCH 7/7] feat: Modify controller to return payloadResponse --- .../lightswitch/controller/UserController.kt | 50 +++++++++---------- .../controller/UserControllerTest.kt | 29 +++-------- 2 files changed, 30 insertions(+), 49 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt b/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt index 64b3f70..8dcbf7b 100644 --- a/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt +++ b/backend/src/main/kotlin/com/lightswitch/controller/UserController.kt @@ -3,12 +3,11 @@ package com.lightswitch.controller import com.lightswitch.application.service.AuthService import com.lightswitch.controller.request.UserAccount import com.lightswitch.infrastructure.security.JwtToken -import com.lightswitch.presentation.exception.BusinessException +import com.lightswitch.presentation.model.PayloadResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.Parameter import jakarta.validation.Valid import jakarta.validation.constraints.NotEmpty -import org.springframework.http.ResponseEntity import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.bind.annotation.* @@ -21,9 +20,13 @@ class UserController(private val authService: AuthService) { description = "Authenticates a user with their username and password, and returns a JWT token." ) @PostMapping("/login") - fun userLogin(@RequestBody @Valid body: UserAccount): ResponseEntity { + fun userLogin(@RequestBody @Valid body: UserAccount): PayloadResponse { val token = authService.login(body.username, body.password) - return ResponseEntity.ok(token) + return PayloadResponse( + "Success", + message = "User Login Success", + data = token + ) } @Operation( @@ -31,13 +34,13 @@ class UserController(private val authService: AuthService) { description = "Registers a new user with the provided username and password. Returns a confirmation message." ) @PostMapping("/initialize") - fun userInitialize(@RequestBody @Valid body: UserAccount): ResponseEntity { - return try { - val user = authService.signup(body.username, body.password) - ResponseEntity.ok("SignUp Completed: ${user.username}") - } catch (e: BusinessException) { - ResponseEntity.badRequest().body(e.message) - } + fun userInitialize(@RequestBody @Valid body: UserAccount): PayloadResponse { + val user = authService.signup(body.username, body.password) + return PayloadResponse( + "Success", + message = "Signup New User Success", + data = user.username + ) } @Operation( @@ -45,11 +48,10 @@ class UserController(private val authService: AuthService) { description = "Reissues a new JWT token using the provided refresh token and current user's identity." ) @PutMapping("/auth/refresh") - fun refreshUserToken(@RequestHeader("Authorization") @NotEmpty @Parameter(description = "The refresh token prefixed with 'Bearer '.") refreshToken: String): ResponseEntity { + fun refreshUserToken(@RequestHeader("Authorization") @NotEmpty @Parameter(description = "The refresh token prefixed with 'Bearer '.") refreshToken: String): PayloadResponse { val authentication: Authentication = SecurityContextHolder.getContext().authentication - val token = - authService.reissue(refreshToken.removePrefix("Bearer "), authentication.name.toLong()) - return ResponseEntity.ok(token) + val token = authService.reissue(refreshToken.removePrefix("Bearer "), authentication.name.toLong()) + return PayloadResponse("Success", "Refresh Token Success", token) } @Operation( @@ -59,15 +61,11 @@ class UserController(private val authService: AuthService) { @PostMapping("/logout") fun userLogout( @RequestHeader("Authorization") @NotEmpty @Parameter(description = "The access token prefixed with 'Bearer '.") accessToken: String, - @RequestBody @Valid body: UserAccount - ): ResponseEntity { - return try { - val authentication: Authentication = SecurityContextHolder.getContext().authentication - val userId = authentication.name - authService.logout(userId.toLong()) - ResponseEntity.ok("Log out Completed: ${body.username}") - } catch (e: IllegalArgumentException) { - ResponseEntity.badRequest().body(e.message) - } + @RequestBody @Valid body: UserAccount, + ): PayloadResponse { + val authentication: Authentication = SecurityContextHolder.getContext().authentication + val userId = authentication.name + authService.logout(userId.toLong()) + return PayloadResponse("Success", "Logout Success", body.username) } -} \ No newline at end of file +} diff --git a/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt b/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt index 2e337b9..880ab87 100644 --- a/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/controller/UserControllerTest.kt @@ -2,7 +2,6 @@ package com.lightswitch.controller import com.lightswitch.application.service.AuthService import com.lightswitch.infrastructure.security.JwtToken -import com.lightswitch.presentation.exception.BusinessException import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -54,27 +53,10 @@ class UserControllerTest { .content("""{"username": "testUser", "password": "password"}""") ) .andExpect(MockMvcResultMatchers.status().isOk) - .andExpect(MockMvcResultMatchers.jsonPath("$.accessToken").value("accessToken")) - .andExpect(MockMvcResultMatchers.jsonPath("$.refreshToken").value("refreshToken")) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.accessToken").value("accessToken")) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.refreshToken").value("refreshToken")) } - @Test - fun `should return bad request when signup fails`() { - `when`( - authService.signup( - "testUser", - "password" - ) - ).thenThrow(BusinessException("User already exists")) - - mockMvc.perform( - MockMvcRequestBuilders.post("/api/v1/users/initialize") - .contentType(MediaType.APPLICATION_JSON) - .content("""{"username": "testUser", "password": "password"}""") - ) - .andExpect(MockMvcResultMatchers.status().isBadRequest) - .andExpect(MockMvcResultMatchers.content().string("User already exists")) - } @Test fun `should refresh user token successfully`() { @@ -89,8 +71,8 @@ class UserControllerTest { .contentType(MediaType.APPLICATION_JSON) ) .andExpect(MockMvcResultMatchers.status().isOk) - .andExpect(MockMvcResultMatchers.jsonPath("$.accessToken").value("newAccessToken")) - .andExpect(MockMvcResultMatchers.jsonPath("$.refreshToken").value("newRefreshToken")) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.accessToken").value("newAccessToken")) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.refreshToken").value("newRefreshToken")) } @Test @@ -102,6 +84,7 @@ class UserControllerTest { .content("""{"username": "testUser", "password": "password"}""") ) .andExpect(MockMvcResultMatchers.status().isOk) - .andExpect(MockMvcResultMatchers.content().string("Log out Completed: testUser")) + .andExpect(MockMvcResultMatchers.jsonPath("$.data").value("testUser")) + } }