Repository navigation
feat: 로그인 구현 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: 로그인 구현 #6
Changes from all commits
144df4c
6c95b15
f9f9e55
5786c82
afc4b64
96fcb3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package com.option.domain.user.controller | ||
|
|
||
| import com.option.domain.user.dto.request.LoginRequest | ||
| import com.option.domain.user.dto.request.ReissueRequest | ||
| import com.option.domain.user.dto.request.SignUpRequest | ||
| import com.option.domain.user.dto.response.TokenResponse | ||
| import com.option.domain.user.service.AuthService | ||
| import com.option.global.apiPayLoad.response.ApiResponse | ||
| import com.option.global.security.annotation.UserId | ||
| import io.swagger.v3.oas.annotations.Operation | ||
| import io.swagger.v3.oas.annotations.tags.Tag | ||
| import jakarta.validation.Valid | ||
| import org.springframework.web.bind.annotation.DeleteMapping | ||
| 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 | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/auth") | ||
| @Tag(name = "Auth", description = "로그인 API") | ||
| class AuthController( | ||
| private val authService: AuthService, | ||
| ) { | ||
| @PostMapping("/sign-up") | ||
| @Operation(summary = "회원가입", description = "새로운 회원 등록") | ||
| fun signUp(@Valid @RequestBody request: SignUpRequest): ApiResponse<Unit> { | ||
| authService.signUp(request) | ||
| return ApiResponse.ok() | ||
| } | ||
|
|
||
| @PostMapping("/login") | ||
| @Operation(summary = "로그인", description = "로그인시 엑세스 토큰과 리프레쉬 토큰을 발급") | ||
| fun login(@Valid @RequestBody request: LoginRequest): ApiResponse<TokenResponse> = | ||
| ApiResponse.ok(authService.login(request)) | ||
|
|
||
| @PostMapping("/reissue") | ||
| @Operation(summary = "토큰 재발급", description = "리프레쉬토큰으로 새로운 토큰들 발급") | ||
| fun reissue(@Valid @RequestBody request: ReissueRequest): ApiResponse<TokenResponse> = | ||
| ApiResponse.ok(authService.reissue(request)) | ||
|
|
||
| @PostMapping("/logout") | ||
| @Operation(summary = "로그아웃", description = "db의 리프레쉬 토큰을 무효화") | ||
| fun logout(@UserId userId: Long): ApiResponse<Unit> { | ||
| authService.logout(userId) | ||
| return ApiResponse.ok() | ||
| } | ||
|
|
||
| @DeleteMapping("/withdraw") | ||
| @Operation(summary = "회원탈퇴", description = "회원 삭제") | ||
| fun withdraw(@UserId userId: Long): ApiResponse<Unit> { | ||
| authService.withdraw(userId) | ||
| return ApiResponse.ok() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.option.domain.user.dto.request | ||
|
|
||
| import jakarta.validation.constraints.NotBlank | ||
|
|
||
| data class LoginRequest( | ||
| @field:NotBlank | ||
| val email: String, | ||
| @field:NotBlank | ||
| val password: String, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package com.option.domain.user.dto.request | ||
|
|
||
| import jakarta.validation.constraints.NotBlank | ||
|
|
||
| data class ReissueRequest( | ||
| @field:NotBlank | ||
| val refreshToken: String, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.option.domain.user.dto.request | ||
|
|
||
| import jakarta.validation.constraints.Email | ||
| import jakarta.validation.constraints.NotBlank | ||
| import jakarta.validation.constraints.Size | ||
|
|
||
| data class SignUpRequest( | ||
| @field:NotBlank | ||
| @field:Email | ||
| @field:Size(max = 255) | ||
| val email: String, | ||
| // BCrypt는 72바이트까지만 보니까 그보다 길게 받지 않는다. | ||
| @field:Size(min = 8, max = 64) | ||
| val password: String, | ||
|
Comment on lines
+13
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 비밀번호 길이 제한이 BCrypt의 72바이트 제한을 보장하지 않습니다. 주석은 BCrypt가 72바이트까지만 처리한다고 설명합니다. 그러나 바이트 수를 직접 검사하세요. 🛡️ 제안 수정
|
||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| package com.option.domain.user.dto.response | ||
|
|
||
| data class TokenResponse( | ||
| val accessToken: String, | ||
| val refreshToken: String, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.option.domain.user.repository | ||
|
|
||
| import com.option.domain.user.entity.User | ||
| import org.springframework.data.jpa.repository.JpaRepository | ||
|
|
||
| interface UserRepository : JpaRepository<User, Long> { | ||
| fun findByEmail(email: String): User? | ||
|
|
||
| fun existsByEmail(email: String): Boolean | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package com.option.domain.user.service | ||
|
|
||
| import com.option.domain.user.dto.request.LoginRequest | ||
| import com.option.domain.user.dto.request.ReissueRequest | ||
| import com.option.domain.user.dto.request.SignUpRequest | ||
| import com.option.domain.user.dto.response.TokenResponse | ||
| import com.option.domain.user.entity.User | ||
| import com.option.domain.user.repository.UserRepository | ||
| import com.option.global.apiPayLoad.error.AuthErrorCode | ||
| import com.option.global.apiPayLoad.exception.BusinessException | ||
| import com.option.global.security.jwt.JwtTokenProvider | ||
| import org.springframework.data.repository.findByIdOrNull | ||
| import org.springframework.security.crypto.password.PasswordEncoder | ||
| import org.springframework.stereotype.Service | ||
| import org.springframework.transaction.annotation.Transactional | ||
|
|
||
| @Service | ||
| class AuthService( | ||
| private val userRepository: UserRepository, | ||
| private val passwordEncoder: PasswordEncoder, | ||
| private val jwtTokenProvider: JwtTokenProvider, | ||
| ) { | ||
| // 회원가입 | ||
| @Transactional | ||
| fun signUp(request: SignUpRequest) { | ||
| if (userRepository.existsByEmail(request.email)) { | ||
| throw BusinessException(AuthErrorCode.EMAIL_ALREADY_EXISTS) | ||
| } | ||
| userRepository.save(User(email = request.email, password = passwordEncoder.encode(request.password))) | ||
|
Comment on lines
+26
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 동시 가입 시 500 오류가 발생할 수 있고, 이메일 대소문자가 구분됩니다.
또한 이메일을 정규화하지 않아 🐛 제안 수정
|
||
| } | ||
|
|
||
| // 로그인 | ||
| @Transactional | ||
| fun login(request: LoginRequest): TokenResponse { | ||
| val user = userRepository.findByEmail(request.email) ?: throw BusinessException(AuthErrorCode.LOGIN_FAILED) | ||
| if (!passwordEncoder.matches(request.password, user.password)) { | ||
| throw BusinessException(AuthErrorCode.LOGIN_FAILED) | ||
| } | ||
| return issueTokens(user) | ||
| } | ||
|
|
||
| // 로그아웃 | ||
| @Transactional | ||
| fun logout(userId: Long) { | ||
| val user = userRepository.findByIdOrNull(userId) ?: throw BusinessException(AuthErrorCode.USER_NOT_FOUND) | ||
| user.refreshToken = null | ||
| userRepository.save(user) | ||
| } | ||
|
|
||
| // 회원탈퇴 | ||
| @Transactional | ||
| fun withdraw(userId: Long) { | ||
| val user = userRepository.findByIdOrNull(userId) ?: throw BusinessException(AuthErrorCode.USER_NOT_FOUND) | ||
| userRepository.delete(user) | ||
| } | ||
|
|
||
| // 토큰 재발급 | ||
| @Transactional | ||
| fun reissue(request: ReissueRequest): TokenResponse { | ||
| val userId = jwtTokenProvider.getUserIdFromRefreshToken(request.refreshToken) | ||
| val user = userRepository.findByIdOrNull(userId) ?: throw BusinessException(AuthErrorCode.INVALID_TOKEN) | ||
| if (user.refreshToken != request.refreshToken) { | ||
| throw BusinessException(AuthErrorCode.INVALID_TOKEN) | ||
| } | ||
| return issueTokens(user) | ||
| } | ||
|
|
||
| // 토큰 생성 | ||
| private fun issueTokens(user: User): TokenResponse { | ||
| val userId = user.id!! | ||
| val accessToken = jwtTokenProvider.createAccessToken(userId) | ||
| val refreshToken = jwtTokenProvider.createRefreshToken(userId) | ||
| user.refreshToken = refreshToken | ||
| userRepository.save(user) | ||
|
Comment on lines
+73
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return TokenResponse(accessToken, refreshToken) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.option.global.config | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper | ||
| import com.option.global.apiPayLoad.error.AuthErrorCode | ||
| import com.option.global.security.jwt.JwtAuthenticationFilter | ||
| import com.option.global.security.jwt.JwtTokenProvider | ||
| import org.springframework.context.annotation.Bean | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.security.config.annotation.web.builders.HttpSecurity | ||
| import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer | ||
| import org.springframework.security.config.http.SessionCreationPolicy | ||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder | ||
| import org.springframework.security.crypto.password.PasswordEncoder | ||
| import org.springframework.security.web.SecurityFilterChain | ||
| import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter | ||
|
|
||
| @Configuration | ||
| class SecurityConfig( | ||
| private val jwtTokenProvider: JwtTokenProvider, | ||
| private val objectMapper: ObjectMapper, | ||
| ) { | ||
| @Bean | ||
| fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder() | ||
|
|
||
| @Bean | ||
| fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { | ||
| val jwtFilter = JwtAuthenticationFilter(jwtTokenProvider, objectMapper) | ||
| http | ||
| .csrf(AbstractHttpConfigurer<*, *>::disable) | ||
| .formLogin(AbstractHttpConfigurer<*, *>::disable) | ||
| .httpBasic(AbstractHttpConfigurer<*, *>::disable) | ||
| .logout(AbstractHttpConfigurer<*, *>::disable) | ||
| .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } | ||
| .authorizeHttpRequests { | ||
| it | ||
| .requestMatchers(*JwtAuthenticationFilter.PUBLIC_PATHS).permitAll() | ||
| .requestMatchers("/actuator/**", "/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| .anyRequest().authenticated() | ||
| } | ||
| .exceptionHandling { | ||
| it.authenticationEntryPoint { _, response, _ -> jwtFilter.writeError(response, AuthErrorCode.UNAUTHORIZED) } | ||
| } | ||
| .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) | ||
| return http.build() | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| package com.option.global.config | ||
|
|
||
| import com.option.global.security.resolver.UserIdArgumentResolver | ||
| import org.springframework.context.annotation.Configuration | ||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver | ||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer | ||
|
|
||
| @Configuration | ||
| class WebMvcConfig( | ||
| private val userIdArgumentResolver: UserIdArgumentResolver, | ||
| ) : WebMvcConfigurer { | ||
|
|
||
| override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { | ||
| resolvers.add(userIdArgumentResolver) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.option.global.properties | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties | ||
| import java.time.Duration | ||
|
|
||
| @ConfigurationProperties("jwt") | ||
| class JwtProperties( | ||
| val secret: String, | ||
| val accessTokenValidity: Duration, | ||
| val refreshTokenValidity: Duration, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.option.global.security.annotation | ||
|
|
||
| import io.swagger.v3.oas.annotations.Parameter | ||
|
|
||
| // userId 파싱용 어노테이션 | ||
| @Target(AnnotationTarget.VALUE_PARAMETER) | ||
| @Retention(AnnotationRetention.RUNTIME) | ||
| @Parameter(hidden = true) | ||
| annotation class UserId |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package com.option.global.security.jwt | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper | ||
| import com.option.global.apiPayLoad.error.ErrorCode | ||
| import com.option.global.apiPayLoad.exception.BusinessException | ||
| import com.option.global.apiPayLoad.response.ApiResponse | ||
| import jakarta.servlet.FilterChain | ||
| import jakarta.servlet.http.HttpServletRequest | ||
| import jakarta.servlet.http.HttpServletResponse | ||
| import org.springframework.http.HttpHeaders | ||
| import org.springframework.http.MediaType | ||
| import org.springframework.security.authentication.UsernamePasswordAuthenticationToken | ||
| import org.springframework.security.core.context.SecurityContextHolder | ||
| import org.springframework.web.filter.OncePerRequestFilter | ||
|
|
||
| class JwtAuthenticationFilter( | ||
| private val jwtTokenProvider: JwtTokenProvider, | ||
| private val objectMapper: ObjectMapper, | ||
| ) : OncePerRequestFilter() { | ||
|
|
||
| override fun shouldNotFilter(request: HttpServletRequest): Boolean = request.requestURI in PUBLIC_PATHS | ||
|
|
||
| override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) { | ||
| val header = request.getHeader(HttpHeaders.AUTHORIZATION) | ||
| // 토큰 없어도 일단 넘김 | ||
| if (header == null || !header.startsWith(BEARER)) { | ||
| filterChain.doFilter(request, response) | ||
| return | ||
| } | ||
| try { | ||
| // 헤더에서 id 추출 | ||
| val userId = jwtTokenProvider.getUserIdFromAccessToken(header.substring(BEARER.length)) | ||
| SecurityContextHolder.getContext().authentication = UsernamePasswordAuthenticationToken(userId, null, emptyList()) | ||
| } catch (e: BusinessException) { | ||
| writeError(response, e.errorCode) | ||
| return | ||
| } | ||
|
|
||
| filterChain.doFilter(request, response) | ||
| } | ||
|
|
||
| // 필터에서는 글로벌익셉션핸들러가 안 돌기 때문에 예외 직접 작성 | ||
| fun writeError(response: HttpServletResponse, errorCode: ErrorCode) { | ||
| response.status = errorCode.httpStatus.value() | ||
| response.contentType = MediaType.APPLICATION_JSON_VALUE | ||
| response.characterEncoding = Charsets.UTF_8.name() | ||
| objectMapper.writeValue(response.writer, ApiResponse.error(errorCode)) | ||
| } | ||
|
|
||
| companion object { | ||
| private const val BEARER = "Bearer " | ||
|
|
||
| val PUBLIC_PATHS = arrayOf("/api/auth/sign-up", "/api/auth/login", "/api/auth/reissue") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
로그아웃과 회원탈퇴 후에도 액세스 토큰이 만료까지 유효하며, 만료 후에는 로그아웃이 불가능합니다.
logout은 DB의refreshToken만 null로 바꿉니다.JwtAuthenticationFilter는 서명과 만료만 검사하므로 발급된 액세스 토큰(30분)은 로그아웃 후에도 통과합니다.withdraw후에도 같습니다. 컨트롤러 설명은 DB 리프레시 토큰 무효화라고 명시하므로 의도된 설계로 보입니다. 즉시 폐기가 필요하면jti기반 denylist(예: Redis, 토큰 만료 시간과 같은 TTL)를 도입하세요./api/auth/logout은PUBLIC_PATHS에 없으므로 유효한 액세스 토큰이 필요합니다. 액세스 토큰이 만료된 사용자는EXPIRED_TOKEN(401)을 받고 로그아웃할 수 없습니다. 이 경우 서버의 리프레시 토큰이 계속 유효합니다. 클라이언트가 먼저reissue를 호출하도록 안내하거나, 리프레시 토큰으로 로그아웃하는 방식을 검토하세요.Based on learnings: JWT는 만료 전에 무효화할 수 없으므로, 폐기가 필요하면 Redis 같은 공유 저장소의 denylist를 사용합니다.
Source: Learnings