Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TokenResponse> 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<Void> logout(
@RequestHeader(value = "refreshToken") String refreshToken
) {
authCommandService.logout(refreshToken);

return BaseResponse.onSuccess(null);
}

// todo : 로그인
@Operation(summary = "native용 로그인 API", description = "네이티브에서 사용하는 로그인 API")
@PostMapping("/login")
public BaseResponse<TokenResponse> login(
@RequestBody MemberLoginReqeust request
) {
return BaseResponse.onSuccess(nativeAuthCommandService.nativeLogin(request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.mody.domain.auth.dto.response;

public record TokenResponse (
String accessToken,
String refreshToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();

// 리다이렉션 수행
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Object> errorResponse = BaseResponse.onFailure(errorCode, errorMessage, null);
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ public CorsConfigurationSource corsConfigurationSource() {
// 허용할 헤더 설정
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"refreshToken",
"Content-Type",
"X-Requested-With",
"Accept",
Expand Down