diff --git a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java index 1a080f47..78b7f739 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -1,14 +1,11 @@ package com.example.mody.domain.auth.controller; +import com.example.mody.domain.auth.dto.response.TokenResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseCookie; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.CookieValue; -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; +import org.springframework.web.bind.annotation.*; import com.example.mody.domain.auth.dto.request.EmailRequest; import com.example.mody.domain.auth.dto.request.EmailVerificationRequest; diff --git a/src/main/java/com/example/mody/domain/auth/controller/NativeAuthController.java b/src/main/java/com/example/mody/domain/auth/controller/NativeAuthController.java new file mode 100644 index 00000000..d6239fca --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/controller/NativeAuthController.java @@ -0,0 +1,59 @@ +package com.example.mody.domain.auth.controller; + +import com.example.mody.domain.auth.dto.request.MemberLoginReqeust; +import com.example.mody.domain.auth.dto.response.TokenResponse; +import com.example.mody.domain.auth.service.AuthCommandService; +import com.example.mody.domain.auth.service.NativeAuthCommandService; +import com.example.mody.domain.auth.service.email.EmailService; +import com.example.mody.domain.member.service.MemberCommandService; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "네이티브 Auth API", description = "인증 관련 API - 회원가입, 로그인, 토큰 재발급, 로그아웃 등의 기능을 제공합니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/native") +public class NativeAuthController { + + private static final Logger log = LoggerFactory.getLogger(AuthController.class); + private final AuthCommandService authCommandService; + private final NativeAuthCommandService nativeAuthCommandService; + private final MemberCommandService memberCommandService; + private final EmailService emailService; + + @Operation(summary = "native용 리이슈 API", description = "네이티브에서 사용하는 리이슈 API") + @PostMapping("/reissue") + public BaseResponse webReissueToken( + @RequestHeader(value = "refreshToken") String refreshToken + ) { + log.info("refreshToken: {}", refreshToken); + return BaseResponse.onSuccess(authCommandService.nativeReissueToken(refreshToken)); + } + + @Operation(summary = "native용 로그아웃 API", description = "native용 로그아웃을 수행하는 API입니다. 리프레시 토큰을 만료시킵니다.") + @PostMapping("/logout") + public BaseResponse logout( + @RequestHeader(value = "refreshToken") String refreshToken + ) { + authCommandService.logout(refreshToken); + + return BaseResponse.onSuccess(null); + } + + // todo : 로그인 + @Operation(summary = "native용 로그인 API", description = "네이티브에서 사용하는 로그인 API") + @PostMapping("/login") + public BaseResponse login( + @RequestBody MemberLoginReqeust request + ) { + return BaseResponse.onSuccess(nativeAuthCommandService.nativeLogin(request)); + } +} diff --git a/src/main/java/com/example/mody/domain/auth/dto/response/TokenResponse.java b/src/main/java/com/example/mody/domain/auth/dto/response/TokenResponse.java new file mode 100644 index 00000000..d3b36e35 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/dto/response/TokenResponse.java @@ -0,0 +1,7 @@ +package com.example.mody.domain.auth.dto.response; + +public record TokenResponse ( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java index 1d419882..88829abf 100644 --- a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -80,6 +80,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo String tempUrl = (!member.isRegistrationCompleted()) ? FRONT_SIGNUP_URL : FRONT_HOME_URL; String targetUrl = UriComponentsBuilder.fromUriString(tempUrl) + .queryParam("refresh-token", newRefreshToken) .build().toUriString(); // 리다이렉션 수행 diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java index 46da073e..737126ec 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -53,7 +53,9 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce uri.startsWith("/v3/api-docs/"); skip = (!uri.startsWith("/auth/signup/complete") - && !uri.startsWith("/auth/logout")) && skip; + && !uri.startsWith("/auth/logout")) + && skip + && !uri.startsWith("/auth/native/logout"); log.info("JwtAuthenticationFilter - shouldNotFilter returns: {}", skip); diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java index 4115a6c4..0d43b574 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandService.java @@ -1,6 +1,7 @@ package com.example.mody.domain.auth.service; import com.example.mody.domain.auth.dto.response.AccessTokenResponse; +import com.example.mody.domain.auth.dto.response.TokenResponse; import com.example.mody.domain.member.entity.Member; import jakarta.servlet.http.HttpServletResponse; @@ -9,6 +10,8 @@ public interface AuthCommandService { AccessTokenResponse reissueToken(String oldRefreshToken, HttpServletResponse response); + TokenResponse nativeReissueToken(String oldRefreshToken); + void saveRefreshToken(Member member, String refreshToken); void logout(String refreshToken); diff --git a/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java index 14db2ca8..d342f812 100644 --- a/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/auth/service/AuthCommandServiceImpl.java @@ -1,5 +1,6 @@ package com.example.mody.domain.auth.service; +import com.example.mody.domain.auth.dto.response.TokenResponse; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,6 +65,31 @@ public AccessTokenResponse reissueToken(String oldRefreshToken, HttpServletRespo .build(); } + // 네이티브 전용 리이슈 + public TokenResponse nativeReissueToken(String oldRefreshToken) { + log.info("Client refresh token: {}", oldRefreshToken); + RefreshToken refreshTokenEntity = refreshTokenRepository.findByToken(oldRefreshToken) + .orElseThrow(() -> { + log.warn("DB에 저장된 refresh token과 일치하는 값이 없습니다."); + return new RefreshTokenException(AuthErrorStatus.INVALID_REFRESH_TOKEN); + }); + log.info("DB refresh token for member {}: {}", refreshTokenEntity.getMember().getId(), + refreshTokenEntity.getToken()); + + // Refresh Token에 해당하는 회원 조회 + Member member = refreshTokenEntity.getMember(); + + // 새로운 토큰 발급 + String newAccessToken = jwtProvider.createAccessToken(member.getId().toString()); + String newRefreshToken = jwtProvider.createRefreshToken(member.getId().toString()); + + // Refresh Token 교체 (Rotation) + refreshTokenEntity.updateToken(newRefreshToken); + + // Refresh Token과 Access Token 반환 + return new TokenResponse(newAccessToken, newRefreshToken); + } + public void saveRefreshToken(Member member, String refreshToken) { // 기존 리프레시 토큰이 있다면 업데이트, 없다면 새로 생성 RefreshToken refreshTokenEntity = refreshTokenRepository.findByMember(member) diff --git a/src/main/java/com/example/mody/domain/auth/service/NativeAuthCommandService.java b/src/main/java/com/example/mody/domain/auth/service/NativeAuthCommandService.java new file mode 100644 index 00000000..0ae44928 --- /dev/null +++ b/src/main/java/com/example/mody/domain/auth/service/NativeAuthCommandService.java @@ -0,0 +1,150 @@ +package com.example.mody.domain.auth.service; + +import com.example.mody.domain.auth.dto.request.MemberLoginReqeust; +import com.example.mody.domain.auth.dto.response.LoginResponse; +import com.example.mody.domain.auth.dto.response.TokenResponse; +import com.example.mody.domain.auth.entity.RefreshToken; +import com.example.mody.domain.auth.jwt.JwtProvider; +import com.example.mody.domain.auth.repository.RefreshTokenRepository; +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.exception.RefreshTokenException; +import com.example.mody.domain.member.entity.Member; +import com.example.mody.domain.member.repository.MemberRepository; +import com.example.mody.global.common.base.BaseResponse; +import com.example.mody.global.common.exception.RestApiException; +import com.example.mody.global.common.exception.code.status.AuthErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; + +@Service +@Transactional +@RequiredArgsConstructor +public class NativeAuthCommandService { + + private final AuthenticationManager authenticationManager; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + private final AuthCommandService authCommandService; + + private final MemberRepository memberRepository; + private final RefreshTokenRepository refreshTokenRepository; + + private final ObjectMapper objectMapper; + + public TokenResponse nativeLogin(MemberLoginReqeust loginReqeust){ + + // 아이디로 멤버 찾아오기 + Member member = memberRepository.findByEmail(loginReqeust.getEmail()) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.AUTHENTICATION_FAILED)); + + // 비밀번호 예외처리 + if (!passwordEncoder.matches(loginReqeust.getPassword(), member.getPassword())) { + throw new RestApiException(AuthErrorStatus.PASSWORD_MISMATCH); + } + + // 토큰 생성 + // 새로운 토큰 발급 + String newAccessToken = jwtProvider.createAccessToken(member.getId().toString()); + String newRefreshToken = jwtProvider.createRefreshToken(member.getId().toString()); + + // Refresh Token 교체 (Rotation) + RefreshToken refreshTokenEntity = refreshTokenRepository.findByMember(member) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_REFRESH_TOKEN)); + refreshTokenEntity.updateToken(newRefreshToken); + + // Refresh Token과 Access Token 반환 + return new TokenResponse(newAccessToken, newRefreshToken); + + } + + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException { + + //Json형식 데이터 추출 + try { + MemberLoginReqeust loginReqeust = objectMapper.readValue(request.getInputStream(), + MemberLoginReqeust.class); + + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + loginReqeust.getEmail(), loginReqeust.getPassword(), null); + + //authenticationManager가 이메일, 비밀번호로 검증을 진행 + return authenticationManager.authenticate(authToken); + + } catch (IOException e) { + throw new RuntimeException("Failed to parse authentication request body", e); + } + } + + //로그인 성공시, JWT토큰 발급 + public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + Authentication authResult) throws IOException, ServletException { + + CustomUserDetails customUserDetails = (CustomUserDetails)authResult.getPrincipal(); + + String username = customUserDetails.getUsername(); + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ID_TOKEN)); + + // Access Token, Refresh Token 발급 + String newAccessToken = authCommandService.processLoginSuccess(member, response); + + // 로그인 응답 데이터 설정 + LoginResponse loginResponse = LoginResponse.of( + member.getId(), + member.getNickname(), + false, + member.isRegistrationCompleted(), + newAccessToken + ); + + // 응답 바디 작성 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(BaseResponse.onSuccess(loginResponse))); + } + + //로그인 실패한 경우 응답처리 + public void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + String errorMessage; + String errorCode = "AUTH401"; // 기본 에러 코드 + + //존재하지 않는 이메일인 경우, 비밀번호가 올라바르지 않은 경우에 따른 예외처리 + if (failed.getCause() instanceof RestApiException) { + RestApiException restApiException = (RestApiException)failed.getCause(); + errorMessage = restApiException.getErrorCode().getMessage(); //"해당 회원은 존재하지 않습니다." + errorCode = restApiException.getErrorCode().getCode(); + } else if (failed instanceof BadCredentialsException) { + errorMessage = "비밀번호가 올바르지 않습니다."; + errorCode = "AUTH_INVALID_PASSWORD"; + } else { + errorMessage = "인증에 실패했습니다."; + } + + // JSON 응답 작성 + BaseResponse errorResponse = BaseResponse.onFailure(errorCode, errorMessage, null); + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + } +} diff --git a/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java b/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java index c63dfbf1..95d337ef 100644 --- a/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java +++ b/src/main/java/com/example/mody/global/common/exception/code/status/AuthErrorStatus.java @@ -28,6 +28,10 @@ public enum AuthErrorStatus implements BaseCodeInterface { // Email Error INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH011", "유효하지 않은 인증 코드입니다."), + + // 로그인 실패 + AUTHENTICATION_FAILED(HttpStatus.BAD_REQUEST, "LOGIN001", "아이디로 유저를 찾을 수 없습니다."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "LOGIN002", "비밀번호가 다름니다.") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 9da5a5c4..31877d42 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -109,6 +109,7 @@ public CorsConfigurationSource corsConfigurationSource() { // 허용할 헤더 설정 configuration.setAllowedHeaders(Arrays.asList( "Authorization", + "refreshToken", "Content-Type", "X-Requested-With", "Accept",