diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fec70bd..d2dc32b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,8 +2,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip networkTimeout=10000 -retries=0 -retryBackOffMs=500 +retries=3 +retryBackOffMs=5000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/kotlin/com/option/domain/user/controller/AuthController.kt b/src/main/kotlin/com/option/domain/user/controller/AuthController.kt new file mode 100644 index 0000000..ddff774 --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/controller/AuthController.kt @@ -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 { + authService.signUp(request) + return ApiResponse.ok() + } + + @PostMapping("/login") + @Operation(summary = "로그인", description = "로그인시 엑세스 토큰과 리프레쉬 토큰을 발급") + fun login(@Valid @RequestBody request: LoginRequest): ApiResponse = + ApiResponse.ok(authService.login(request)) + + @PostMapping("/reissue") + @Operation(summary = "토큰 재발급", description = "리프레쉬토큰으로 새로운 토큰들 발급") + fun reissue(@Valid @RequestBody request: ReissueRequest): ApiResponse = + ApiResponse.ok(authService.reissue(request)) + + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "db의 리프레쉬 토큰을 무효화") + fun logout(@UserId userId: Long): ApiResponse { + authService.logout(userId) + return ApiResponse.ok() + } + + @DeleteMapping("/withdraw") + @Operation(summary = "회원탈퇴", description = "회원 삭제") + fun withdraw(@UserId userId: Long): ApiResponse { + authService.withdraw(userId) + return ApiResponse.ok() + } +} diff --git a/src/main/kotlin/com/option/domain/user/dto/request/LoginRequest.kt b/src/main/kotlin/com/option/domain/user/dto/request/LoginRequest.kt new file mode 100644 index 0000000..e7aa566 --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/dto/request/LoginRequest.kt @@ -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, +) diff --git a/src/main/kotlin/com/option/domain/user/dto/request/ReissueRequest.kt b/src/main/kotlin/com/option/domain/user/dto/request/ReissueRequest.kt new file mode 100644 index 0000000..0631b6f --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/dto/request/ReissueRequest.kt @@ -0,0 +1,8 @@ +package com.option.domain.user.dto.request + +import jakarta.validation.constraints.NotBlank + +data class ReissueRequest( + @field:NotBlank + val refreshToken: String, +) diff --git a/src/main/kotlin/com/option/domain/user/dto/request/SignUpRequest.kt b/src/main/kotlin/com/option/domain/user/dto/request/SignUpRequest.kt new file mode 100644 index 0000000..1707f89 --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/dto/request/SignUpRequest.kt @@ -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, +) diff --git a/src/main/kotlin/com/option/domain/user/dto/response/TokenResponse.kt b/src/main/kotlin/com/option/domain/user/dto/response/TokenResponse.kt new file mode 100644 index 0000000..b5c06ef --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/dto/response/TokenResponse.kt @@ -0,0 +1,6 @@ +package com.option.domain.user.dto.response + +data class TokenResponse( + val accessToken: String, + val refreshToken: String, +) diff --git a/src/main/kotlin/com/option/domain/user/repository/UserRepository.kt b/src/main/kotlin/com/option/domain/user/repository/UserRepository.kt new file mode 100644 index 0000000..d7fd40f --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/repository/UserRepository.kt @@ -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 { + fun findByEmail(email: String): User? + + fun existsByEmail(email: String): Boolean +} diff --git a/src/main/kotlin/com/option/domain/user/service/AuthService.kt b/src/main/kotlin/com/option/domain/user/service/AuthService.kt new file mode 100644 index 0000000..598e415 --- /dev/null +++ b/src/main/kotlin/com/option/domain/user/service/AuthService.kt @@ -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))) + } + + // 로그인 + @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) + return TokenResponse(accessToken, refreshToken) + } +} diff --git a/src/main/kotlin/com/option/global/apiPayLoad/error/AuthErrorCode.kt b/src/main/kotlin/com/option/global/apiPayLoad/error/AuthErrorCode.kt index f31f34f..f1968f8 100644 --- a/src/main/kotlin/com/option/global/apiPayLoad/error/AuthErrorCode.kt +++ b/src/main/kotlin/com/option/global/apiPayLoad/error/AuthErrorCode.kt @@ -13,4 +13,5 @@ enum class AuthErrorCode( INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A004", "토큰이 올바르지 않습니다."), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A005", "토큰이 만료되었습니다."), USER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A006", "계정을 찾을 수 없습니다."), + JWT_SECRET_MISSING(HttpStatus.INTERNAL_SERVER_ERROR, "A007", "JWT 시크릿이 설정되지 않았습니다."), } diff --git a/src/main/kotlin/com/option/global/config/SecurityConfig.kt b/src/main/kotlin/com/option/global/config/SecurityConfig.kt new file mode 100644 index 0000000..d20462c --- /dev/null +++ b/src/main/kotlin/com/option/global/config/SecurityConfig.kt @@ -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() + .anyRequest().authenticated() + } + .exceptionHandling { + it.authenticationEntryPoint { _, response, _ -> jwtFilter.writeError(response, AuthErrorCode.UNAUTHORIZED) } + } + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) + return http.build() + } +} diff --git a/src/main/kotlin/com/option/global/config/WebMvcConfig.kt b/src/main/kotlin/com/option/global/config/WebMvcConfig.kt new file mode 100644 index 0000000..983ae17 --- /dev/null +++ b/src/main/kotlin/com/option/global/config/WebMvcConfig.kt @@ -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) { + resolvers.add(userIdArgumentResolver) + } +} diff --git a/src/main/kotlin/com/option/global/properties/JwtProperties.kt b/src/main/kotlin/com/option/global/properties/JwtProperties.kt new file mode 100644 index 0000000..f56f6d7 --- /dev/null +++ b/src/main/kotlin/com/option/global/properties/JwtProperties.kt @@ -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, +) diff --git a/src/main/kotlin/com/option/global/security/annotation/UserId.kt b/src/main/kotlin/com/option/global/security/annotation/UserId.kt new file mode 100644 index 0000000..a198538 --- /dev/null +++ b/src/main/kotlin/com/option/global/security/annotation/UserId.kt @@ -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 diff --git a/src/main/kotlin/com/option/global/security/jwt/JwtAuthenticationFilter.kt b/src/main/kotlin/com/option/global/security/jwt/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..c3e4995 --- /dev/null +++ b/src/main/kotlin/com/option/global/security/jwt/JwtAuthenticationFilter.kt @@ -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") + } +} diff --git a/src/main/kotlin/com/option/global/security/jwt/JwtTokenProvider.kt b/src/main/kotlin/com/option/global/security/jwt/JwtTokenProvider.kt new file mode 100644 index 0000000..ebf3af1 --- /dev/null +++ b/src/main/kotlin/com/option/global/security/jwt/JwtTokenProvider.kt @@ -0,0 +1,82 @@ +package com.option.global.security.jwt + +import com.option.global.apiPayLoad.error.AuthErrorCode +import com.option.global.apiPayLoad.exception.BusinessException +import com.option.global.properties.JwtProperties +import io.jsonwebtoken.Claims +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.JwtParser +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.io.Decoders +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.Duration +import java.util.Date +import java.util.UUID +import javax.crypto.SecretKey + +@Component +class JwtTokenProvider( + private val jwtProperties: JwtProperties, + private val clock: Clock, +) { + private val secretKey: SecretKey + private val parser: JwtParser + + init { + if (jwtProperties.secret.isBlank()) throw BusinessException(AuthErrorCode.JWT_SECRET_MISSING) + secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtProperties.secret)) + parser = Jwts.parser().verifyWith(secretKey).clock { Date.from(clock.instant()) }.build() + } + + fun createAccessToken(userId: Long): String = createToken(userId, ACCESS, jwtProperties.accessTokenValidity) + + fun createRefreshToken(userId: Long): String = createToken(userId, REFRESH, jwtProperties.refreshTokenValidity) + + fun getUserIdFromAccessToken(token: String): Long = getUserId(token, ACCESS) + + fun getUserIdFromRefreshToken(token: String): Long = getUserId(token, REFRESH) + + // 토큰 생성 + private fun createToken(userId: Long, category: String, validity: Duration): String { + val now = clock.instant() + return Jwts.builder() + .subject(userId.toString()) + .id(UUID.randomUUID().toString()) // 토큰 고유값 + .claim(CATEGORY, category) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(validity))) + .signWith(secretKey) + .compact() + } + + // 사용자 id 불러오기 + private fun getUserId(token: String, category: String): Long { + val claims = parseClaims(token) + if (claims.get(CATEGORY, String::class.java) != category) { + throw BusinessException(AuthErrorCode.INVALID_TOKEN) + } + return claims.subject.toLong() + } + + // 서명 확인 + private fun parseClaims(token: String): Claims { + try { + return parser.parseSignedClaims(token).payload + } catch (e: ExpiredJwtException) { + throw BusinessException(AuthErrorCode.EXPIRED_TOKEN) + } catch (e: JwtException) { + throw BusinessException(AuthErrorCode.INVALID_TOKEN) + } catch (e: IllegalArgumentException) { + throw BusinessException(AuthErrorCode.INVALID_TOKEN) + } + } + + companion object { + private const val CATEGORY = "category" + private const val ACCESS = "access" + private const val REFRESH = "refresh" + } +} diff --git a/src/main/kotlin/com/option/global/security/resolver/UserIdArgumentResolver.kt b/src/main/kotlin/com/option/global/security/resolver/UserIdArgumentResolver.kt new file mode 100644 index 0000000..b9aa346 --- /dev/null +++ b/src/main/kotlin/com/option/global/security/resolver/UserIdArgumentResolver.kt @@ -0,0 +1,30 @@ +package com.option.global.security.resolver + +import com.option.global.apiPayLoad.error.AuthErrorCode +import com.option.global.apiPayLoad.exception.BusinessException +import com.option.global.security.annotation.UserId +import org.springframework.core.MethodParameter +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +// 필터에서 시큐리티컨텍스트의 userId 파싱 +@Component +class UserIdArgumentResolver : HandlerMethodArgumentResolver { + + override fun supportsParameter(parameter: MethodParameter): Boolean = + parameter.hasParameterAnnotation(UserId::class.java) && parameter.parameterType == Long::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Any { + return SecurityContextHolder.getContext().authentication?.principal as? Long + ?: throw BusinessException(AuthErrorCode.UNAUTHORIZED) + } +}