diff --git a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt index 7373423..8aa514e 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/controller/UserAuthController.kt @@ -1,16 +1,19 @@ package busanVibe.busan.domain.user.controller import busanVibe.busan.domain.user.data.dto.login.TokenResponseDto +import busanVibe.busan.domain.user.data.dto.login.UserLoginRequestDTO import busanVibe.busan.domain.user.data.dto.login.UserLoginResponseDTO import busanVibe.busan.domain.user.service.UserCommandService import busanVibe.busan.domain.user.util.LoginRedirectUtil import busanVibe.busan.global.apiPayload.exception.ApiResponse import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid import org.springframework.http.HttpStatus 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.RequestParam import org.springframework.web.bind.annotation.RestController @@ -30,12 +33,34 @@ class UserAuthController ( return ResponseEntity.status(HttpStatus.FOUND).headers(redirectHeader).build() } - @PostMapping("/guest/login") + @PostMapping("/login/guest") @Operation(summary = "게스트 로그인 API", description = "1회용 계정 로그인입니다. 재로그인이 불가능합니다.") fun guestLogin(): ApiResponse{ val userResponse: UserLoginResponseDTO.LoginDto = userCommandService.guestLogin() return ApiResponse.onSuccess(userResponse.tokenResponseDTO) } + @PostMapping("/login/local") + @Operation(summary = "로컬 로그인 API") + fun localLogin(@Valid @RequestBody requestDTO: UserLoginRequestDTO.LocalLoginDto): ApiResponse{ + val userResponse = userCommandService.localLogin(requestDTO) + return ApiResponse.onSuccess(userResponse.tokenResponseDTO) + } + + @PostMapping("/signup/local") + @Operation(summary = "로컬 회원가입 API", + description = + """ + email: 이메일 형식 안 지킬 시 오류 발생 + password: 4~20글자. 형식 자유. + """ + ) + fun localSignUp(@Valid @RequestBody requestDTO: UserLoginRequestDTO.LocalSignUpDto): ResponseEntity{ + userCommandService.localSignUp(requestDTO) + return ResponseEntity.ok().build() + } + + + } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt b/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt index 691481d..64a24eb 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/data/User.kt @@ -1,8 +1,11 @@ package busanVibe.busan.domain.user.data import busanVibe.busan.domain.common.BaseEntity +import busanVibe.busan.domain.user.enums.LoginType import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id @@ -35,15 +38,22 @@ class User( @Column(nullable = true, length = 255) var profileImageUrl: String?, + @Column(nullable = true, length = 255) + var passwordHash: String? = null, + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + val loginType: LoginType + -) : BaseEntity(), UserDetails { + ) : BaseEntity(), UserDetails { override fun getAuthorities(): Collection? { return arrayListOf(SimpleGrantedAuthority("ROLE_USER")) } override fun getPassword(): String? { - return null; + return passwordHash.toString(); } override fun getUsername(): String? { diff --git a/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginRequestDTO.kt b/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginRequestDTO.kt new file mode 100644 index 0000000..8363d86 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginRequestDTO.kt @@ -0,0 +1,29 @@ +package busanVibe.busan.domain.user.data.dto.login + +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + +class UserLoginRequestDTO { + + data class LocalSignUpDto( + @field:NotBlank(message = "이메일은 필수 입력값입니다.") + @field:Email(message = "올바르지 않은 이메일 형식입니다.") + val email: String, + + @field:NotBlank(message = "비밀번호는 필수입니다.") + @field:Size(min = 4, max = 20, message = "비밀번호는 4자 이상 20자 이하로 입력해야 합니다.") + val password: String + ) + + data class LocalLoginDto( + @field:NotBlank(message = "이메일은 필수 입력값입니다.") + @field:Email(message = "올바르지 않은 이메일 형식입니다.") + val email: String, + + @field:NotBlank(message = "비밀번호는 필수입니다.") + val password: String + + ) + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginResponseDTO.kt b/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginResponseDTO.kt index da074df..5780098 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginResponseDTO.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/data/dto/login/UserLoginResponseDTO.kt @@ -2,7 +2,7 @@ package busanVibe.busan.domain.user.data.dto.login class UserLoginResponseDTO { - companion object class LoginDto( + data class LoginDto( val id: Long?, val tokenResponseDTO: TokenResponseDto, val email: String?, diff --git a/src/main/kotlin/busanVibe/busan/domain/user/enums/LoginType.kt b/src/main/kotlin/busanVibe/busan/domain/user/enums/LoginType.kt new file mode 100644 index 0000000..fe59bb7 --- /dev/null +++ b/src/main/kotlin/busanVibe/busan/domain/user/enums/LoginType.kt @@ -0,0 +1,9 @@ +package busanVibe.busan.domain.user.enums + +enum class LoginType { + + GUEST, + LOCAL, + KAKAO + +} \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/user/repository/UserRepository.kt b/src/main/kotlin/busanVibe/busan/domain/user/repository/UserRepository.kt index 5bbaec6..29c68b9 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/repository/UserRepository.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/repository/UserRepository.kt @@ -8,4 +8,6 @@ interface UserRepository : JpaRepository { fun findByEmail(email: String?): Optional fun findUsersByIdIn(ids: List): List + + fun existsByEmail(email: String): Boolean } \ No newline at end of file diff --git a/src/main/kotlin/busanVibe/busan/domain/user/service/UserCommandService.kt b/src/main/kotlin/busanVibe/busan/domain/user/service/UserCommandService.kt index b3236cf..515653e 100644 --- a/src/main/kotlin/busanVibe/busan/domain/user/service/UserCommandService.kt +++ b/src/main/kotlin/busanVibe/busan/domain/user/service/UserCommandService.kt @@ -4,16 +4,21 @@ import busanVibe.busan.domain.user.converter.UserConverter import busanVibe.busan.domain.user.data.User import busanVibe.busan.domain.user.data.dto.login.KaKaoUserInfoResponseDTO import busanVibe.busan.domain.user.data.dto.login.KakaoTokenResponseDTO +import busanVibe.busan.domain.user.data.dto.login.UserLoginRequestDTO import busanVibe.busan.domain.user.data.dto.login.UserLoginResponseDTO +import busanVibe.busan.domain.user.enums.LoginType import busanVibe.busan.domain.user.repository.UserRepository import busanVibe.busan.global.apiPayload.code.status.ErrorStatus import busanVibe.busan.global.apiPayload.exception.GeneralException +import busanVibe.busan.global.apiPayload.exception.handler.ExceptionHandler import busanVibe.busan.global.config.security.JwtTokenProvider +import io.lettuce.core.KillArgs.Builder.user import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatusCode +import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.stereotype.Service import org.springframework.web.reactive.function.BodyInserters import org.springframework.web.reactive.function.client.WebClient @@ -29,7 +34,8 @@ class UserCommandService( private val kakaoUserInfoWebClient: WebClient, private val userRepository: UserRepository, private val userConverter: UserConverter, - private val jwtTokenProvider: JwtTokenProvider + private val jwtTokenProvider: JwtTokenProvider, + private val passwordEncoder: PasswordEncoder ) { @Value("\${spring.kakao.client-id}") @@ -46,10 +52,73 @@ class UserCommandService( val nickname = UUID.randomUUID().toString().substring(0, 7) val profileImageUrl: String? = null - return isNewUser(email, nickname, profileImageUrl) + return isNewUser(email, nickname, profileImageUrl, LoginType.GUEST) } + /** + * 로컬 로그인 + */ + fun localLogin(requestDTO: UserLoginRequestDTO.LocalLoginDto): UserLoginResponseDTO.LoginDto{ + // request body 로부터 정보 가져옴 + val email = requestDTO.email + val password = requestDTO.password + + // 유저 조회 + val user = userRepository.findByEmail(email) + .orElseThrow { ExceptionHandler(ErrorStatus.USER_NOT_FOUND) } + + // 비밀번호 검증 - 틀리면 예외 발생 + if(!passwordEncoder.matches(password, user.passwordHash)){ + throw GeneralException(ErrorStatus.LOGIN_INVALID_PASSWORD) + } + + // 토큰 생성 및 반환 + log.info("[LOCAL LOGIN] email : {}", email) + val token = jwtTokenProvider.createToken(user) + return userConverter.toLoginDto(user, false, token) + } + + /** + * 로컬 회원가입 + */ + fun localSignUp(requestDTO: UserLoginRequestDTO.LocalSignUpDto) { + + // 요청받은 값들 + val email = requestDTO.email + val password = requestDTO.password + + // 이메일 중복 검사 + if(userRepository.existsByEmail(email)) // 이메일 이미 존재하면 예외 발생 + throw GeneralException(ErrorStatus.SIGNUP_EMAIL_EXISTS) + + // 이메일 앞부분 추출하여 nickname 생성 + lateinit var nickname: String + try{ + nickname = email.substring(0, email.indexOf("@")) + }catch (e: Exception) { + throw GeneralException(ErrorStatus.INVALID_EMAIL_STYLE) + } + val profileImageUrl: String? = null + + // password encode + val encodedPassword = passwordEncoder.encode(password) + + // 유저 저장 + userRepository.save(User( + email = email, + nickname = nickname, + profileImageUrl = profileImageUrl, + loginType = LoginType.LOCAL, + passwordHash = encodedPassword + )) + + + } + + /** + * 카카오 로그인 + */ fun loginOrRegisterByKakao(code: String): UserLoginResponseDTO.LoginDto { val token: KakaoTokenResponseDTO = getKakaoToken(code) val userInfo = getUserInfo(token.accessToken) @@ -58,7 +127,7 @@ class UserCommandService( val nickname = userInfo.kakaoAccount.profile?.nickName ?: "\uCE74\uCE74\uC624 \uC0AC\uC6A9\uC790" val profileImageUrl = userInfo.kakaoAccount.profile?.profileImageUrl ?: "" - return isNewUser(email, nickname, profileImageUrl) + return isNewUser(email, nickname, profileImageUrl, LoginType.KAKAO) } private fun getKakaoToken(code: String): KakaoTokenResponseDTO { @@ -102,20 +171,22 @@ class UserCommandService( email: String, nickname: String, profileImageUrl: String?, + loginType: LoginType ): UserLoginResponseDTO.LoginDto { val user = userRepository.findByEmail(email) return user.map { - log.info("기존 유저 로그인: {}", email) + log.info("[{} LOGIN] 기존 유저 로그인: {}", loginType.name, email) val token = jwtTokenProvider.createToken(it) userConverter.toLoginDto(it, false, token) }.orElseGet { - log.info("신규 유저 회원가입: {}", email) + log.info("[{} LOGIN] 신규 유저 회원가입: {}", loginType.name, email) val newUser = User( email = email, nickname = nickname, - profileImageUrl = profileImageUrl + profileImageUrl = profileImageUrl, + loginType = loginType ) userRepository.save(newUser) diff --git a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt index 171357a..d369747 100644 --- a/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt +++ b/src/main/kotlin/busanVibe/busan/global/apiPayload/code/status/ErrorStatus.kt @@ -21,6 +21,11 @@ enum class ErrorStatus( // 사용자 관련 에러 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4004", "유저를 찾을 수 없습니다."), + // 로그인 관련 + SIGNUP_EMAIL_EXISTS(HttpStatus.BAD_REQUEST, "LOGIN4001", "이미 존재하는 이메일입니다."), + INVALID_EMAIL_STYLE(HttpStatus.BAD_REQUEST, "LOGIN4002", "이메일 형식이 올바르지 않습니다."), + LOGIN_INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "LOGIN4003", "비밀번호가 올바르지 않습니다."), + // 명소 관련 에러 PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "PLACE4004", "명소를 찾을 수 없습니다."), diff --git a/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt b/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt index e34c4dd..20ed55e 100644 --- a/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt +++ b/src/main/kotlin/busanVibe/busan/global/config/security/SecurityConfig.kt @@ -40,7 +40,9 @@ class SecurityConfig { "/v3/api-docs/**", "/users/oauth/kakao", "/ws-chat/**", - "/users/guest/login" + "/users/guest/login", + "/users/login/**", + "/users/signup/**" ).permitAll() .anyRequest().authenticated() }