Skip to content
Open
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
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

// OAuth2 login
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// WebClient
implementation 'org.springframework.boot:spring-boot-starter-webflux'

// Netty
implementation 'io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
import com.example.umc8th.domain.member.dto.request.MemberRequestDTO;
import com.example.umc8th.domain.member.dto.response.MemberResponseDTO;
import com.example.umc8th.domain.member.service.command.MemberCommandService;
import com.example.umc8th.domain.member.service.command.OAuth2Service;
import com.example.umc8th.global.apiPayload.CustomResponse;
import com.example.umc8th.global.jwt.dto.JwtDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
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.*;

@RestController
@RequestMapping("/auth")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.example.umc8th.domain.member.controller;

import com.example.umc8th.domain.member.service.command.OAuth2Service;
import com.example.umc8th.global.apiPayload.CustomResponse;
import com.example.umc8th.global.jwt.dto.JwtDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class OAuth2Controller {

private final OAuth2Service oAuth2Service;

@GetMapping("/oauth2/callback/kakao")
public CustomResponse<JwtDTO> loginWithKakao(@RequestParam("code") String code) {
JwtDTO jwtDTO = oAuth2Service.loginWithKakao(code);
return CustomResponse.onSuccess(jwtDTO);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ public class MemberConverter {
public static MemberResponseDTO.SignUp toSignUpResponseDTO(Member member) {
return MemberResponseDTO.SignUp.builder()
.id(member.getId())
.email(member.getEmail())
.username(member.getUsername())
.build();
}

// SignUpRequestDTO -> Member
public static Member toMember(MemberRequestDTO.SignUp reqDTO, PasswordEncoder passwordEncoder) {
return Member.builder()
.email(reqDTO.email())
.username(reqDTO.username())
.password(passwordEncoder.encode(reqDTO.password()))
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
public class MemberRequestDTO {

public record SignUp(
String email,
String username,
String password
) {
}

public record login(
String username,
String email,
String password
){
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class MemberResponseDTO {
@Builder
public record SignUp(
Long id,
String email,
String username
) {
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.example.umc8th.domain.member.dto.response;

public class OAuth2DTO {

public record OAuth2TokenDTO(
String token_type,
String access_token,
String refresh_token,
Long expires_in,
Long refresh_token_expires_in,
String scope
) {}

public record KakaoProfile(
Long id,
String connected_at,
Properties properties,
KakaoAccount kakao_account
) {
public record Properties(
String nickname,
String profile_image,
String thumbnail_image
) {}

public record KakaoAccount(
String email,
Boolean is_email_verified,
Boolean email_needs_agreement,
Boolean has_email,
Boolean profile_nickname_needs_agreement,
Boolean profile_image_needs_agreement,
Boolean email_needs_argument,
Boolean is_email_valid,
Profile profile
) {
public record Profile(
String nickname,
String thumbnail_image_url,
String profile_image_url,
Boolean is_default_nickname,
Boolean is_default_image
) {}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ public class Member {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "username", unique = true, nullable = false)
@Column(name = "email")
private String email;

@Column(name = "username", unique = true)
private String username;

@Column(name = "password", nullable = false)
@Column(name = "password")
private String password;

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
public enum MemberErrorCode implements BaseErrorCode {

MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE404_0", "해당 사용자를 찾을 수 없습니다."),
OAUTH_USER_INFO_FAIL(HttpStatus.NOT_FOUND, "MEMBER404_2", "사용자 정보 조회 실패"),
OAUTH_TOKEN_FAIL(HttpStatus.BAD_REQUEST, "MEMBER400_1", "토큰 변환 실패"),
OAUTH_EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER400_2", "이메일 정보를 찾을 수 없습니다."),
OAUTH_LOGIN_FAIL(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "로그인에 실패하였습니다.")
;

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findByUsername(String username);
Optional<Member> findByEmail(String email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public MemberResponseDTO.SignUp signUp(MemberRequestDTO.SignUp reqDTO) {

@Override
public JwtDTO login(MemberRequestDTO.login reqDTO) {
Member member = memberRepository.findByUsername(reqDTO.username())
Member member = memberRepository.findByEmail(reqDTO.email())
.orElseThrow(() -> new MemberException(MemberErrorCode.MEMBER_NOT_FOUND));

if (!passwordEncoder.matches(reqDTO.password(), member.getPassword())) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.umc8th.domain.member.service.command;

import com.example.umc8th.global.jwt.dto.JwtDTO;

public interface OAuth2Service {

JwtDTO loginWithKakao(String code);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package com.example.umc8th.domain.member.service.command;

import com.example.umc8th.domain.member.dto.response.OAuth2DTO;
import com.example.umc8th.domain.member.entity.Member;
import com.example.umc8th.domain.member.exception.MemberErrorCode;
import com.example.umc8th.domain.member.exception.MemberException;
import com.example.umc8th.domain.member.repository.MemberRepository;
import com.example.umc8th.global.jwt.dto.JwtDTO;
import com.example.umc8th.global.jwt.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Service
@RequiredArgsConstructor
public class OAuth2ServiceImpl implements OAuth2Service {

@Value("${spring.security.oauth2.client.provider.kakao.token-uri}")
private String tokenURI;

@Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}")
private String userInfoURI;

@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String clientId;

@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String redirectURI;

@Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
private String clientSecret;

private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
private final WebClient webClient = WebClient.builder().build();

@Override
public JwtDTO loginWithKakao(String code) {
try {
// 1. Access Token 요청
System.out.println("Access Token 요청 중...");
OAuth2DTO.OAuth2TokenDTO tokenDto = getAccessToken(code);

// 2. 사용자 정보 요청
System.out.println("사용자 정보 요청 중...");
OAuth2DTO.KakaoProfile kakaoProfile = getUserInfo(tokenDto.access_token());

// 3. 이메일 확인
String email = kakaoProfile.kakao_account().email();
System.out.println("이메일 확인: " + email);
if (email == null) {
throw new MemberException(MemberErrorCode.OAUTH_EMAIL_NOT_FOUND);
}

// 4. 데이터베이스에서 사용자 조회 또는 신규 사용자 저장
System.out.println("사용자 조회 또는 저장 중...");
Member member = memberRepository.findByEmail(email)
.orElseGet(() -> memberRepository.save(
Member.builder()
.email(email)
.build()));

// 5. JWT 토큰 발급
System.out.println("JWT 토큰 발급 중...");
return JwtDTO.builder()
.accessToken(jwtUtil.createAccessToken(member))
.refreshToken(jwtUtil.createRefreshToken(member))
.build();

} catch (Exception e) {
e.printStackTrace();
throw new MemberException(MemberErrorCode.OAUTH_LOGIN_FAIL);
}
}

private OAuth2DTO.OAuth2TokenDTO getAccessToken(String code) {
try {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "authorization_code");
formData.add("client_id", clientId);
formData.add("redirect_uri", redirectURI);
formData.add("code", code);
formData.add("client_secret", clientSecret);

System.out.println("=== AccessToken 요청 정보 ===");
System.out.println("토큰 URI: " + tokenURI);
System.out.println("클라이언트 ID: " + clientId);
System.out.println("리다이렉트 URI: " + redirectURI);
System.out.println("인증 코드: " + code);

return webClient.post()
.uri(tokenURI)
.header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded")
.bodyValue(formData)
.retrieve()
.onStatus(HttpStatusCode::isError, response -> {
System.err.println("토큰 요청 오류: " + response.statusCode());
return response.bodyToMono(String.class)
.flatMap(errorBody -> {
System.err.println("오류 응답: " + errorBody);
return Mono.error(new RuntimeException("토큰 요청 실패: " + errorBody));
});
})
.bodyToMono(OAuth2DTO.OAuth2TokenDTO.class)
.doOnSuccess(token -> System.out.println("토큰 발급 성공: " + token.access_token()))
.doOnError(e -> {
System.err.println("토큰 요청 오류: " + e.getMessage());
e.printStackTrace();
})
.onErrorMap(e -> new MemberException(MemberErrorCode.OAUTH_TOKEN_FAIL))
.block();
} catch (Exception e) {
System.err.println("예외 발생: " + e.getMessage());
e.printStackTrace();
throw new MemberException(MemberErrorCode.OAUTH_TOKEN_FAIL);
}
}

private OAuth2DTO.KakaoProfile getUserInfo(String accessToken) {
return webClient.get()
.uri(userInfoURI)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken)
.header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8")
.retrieve()
.bodyToMono(OAuth2DTO.KakaoProfile.class)
.onErrorMap(e -> new MemberException(MemberErrorCode.OAUTH_USER_INFO_FAIL)) // 에러 처리
.block();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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;
Expand All @@ -29,13 +30,14 @@ public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

// 아래 3개는 Swagger에 대한 URL
private String[] allowUrl = {
"/auth/sign-up",
"/auth/login",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
"/oauth2/callback/kakao",
"/oauth2/authorization/kakao"
};

@Bean
Expand All @@ -45,8 +47,11 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers(allowUrl).permitAll()
.anyRequest().authenticated()
)
.cors(cors -> cors.configurationSource(CorsConfig.apiConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.oauth2Login(Customizer.withDefaults())
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
Expand Down