From c2ee5bc65cabe4d9c783214b5b706d3baadd40b7 Mon Sep 17 00:00:00 2001 From: hyoin Date: Tue, 13 May 2025 16:21:01 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20[feat]=20OAuth2=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../umc8th/controller/MemberController.java | 7 + .../example/umc8th/dto/MemberResponseDTO.java | 15 ++ .../com/example/umc8th/dto/OAuth2DTO.java | 54 +++++++ .../com/example/umc8th/entity/Member.java | 6 + .../umc8th/exception/MemberErrorCode.java | 5 +- .../umc8th/global/config/JwtFilter.java | 14 +- .../umc8th/global/config/SecurityConfig.java | 6 + .../umc8th/repository/MemberRepository.java | 1 + .../service/command/TokenCommandService.java | 3 + .../command/impl/TokenCommandServiceImpl.java | 10 ++ .../umc8th/service/oauth/OAuth2Service.java | 7 + .../service/oauth/OAuth2ServiceImpl.java | 147 ++++++++++++++++++ 13 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/example/umc8th/dto/OAuth2DTO.java create mode 100644 src/main/java/com/example/umc8th/service/oauth/OAuth2Service.java create mode 100644 src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java diff --git a/build.gradle b/build.gradle index cd28b36..da734cf 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,9 @@ 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' } tasks.named('test') { diff --git a/src/main/java/com/example/umc8th/controller/MemberController.java b/src/main/java/com/example/umc8th/controller/MemberController.java index 691f60c..5ba7cff 100644 --- a/src/main/java/com/example/umc8th/controller/MemberController.java +++ b/src/main/java/com/example/umc8th/controller/MemberController.java @@ -5,6 +5,7 @@ import com.example.umc8th.entity.Member; import com.example.umc8th.global.apiPayload.CustomResponse; import com.example.umc8th.service.command.MemberCommandService; +import com.example.umc8th.service.oauth.OAuth2Service; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -14,6 +15,7 @@ public class MemberController { private final MemberCommandService memberCommandService; + private final OAuth2Service oAuth2Service; @PostMapping("/sign-up") public CustomResponse signUp(@RequestBody MemberRequestDTO.SignUpRequestDTO dto) { @@ -26,4 +28,9 @@ public CustomResponse login(@RequestBody Mem MemberResponseDTO.LoginResponseDTO member = memberCommandService.login(dto); return CustomResponse.ok(member); } + + @GetMapping("/oauth2/callback/kakao") + public CustomResponse loginWithKakao(@RequestParam("code") String code) { + return CustomResponse.ok(oAuth2Service.login(code)); + } } \ No newline at end of file diff --git a/src/main/java/com/example/umc8th/dto/MemberResponseDTO.java b/src/main/java/com/example/umc8th/dto/MemberResponseDTO.java index 85ad587..c8890b7 100644 --- a/src/main/java/com/example/umc8th/dto/MemberResponseDTO.java +++ b/src/main/java/com/example/umc8th/dto/MemberResponseDTO.java @@ -37,4 +37,19 @@ public static LoginResponseDTO from(Member member) { } } + + @Getter + @Builder + public static class MemberTokenDTO{ + private Long id; + private String accessToken; + private String refreshToken; + public static MemberTokenDTO from(Member member) { + return MemberTokenDTO.builder() + .id(member.getId()) + .accessToken(member.getAccessToken()) + .refreshToken(member.getRefreshToken()) + .build(); + } + } } diff --git a/src/main/java/com/example/umc8th/dto/OAuth2DTO.java b/src/main/java/com/example/umc8th/dto/OAuth2DTO.java new file mode 100644 index 0000000..8579362 --- /dev/null +++ b/src/main/java/com/example/umc8th/dto/OAuth2DTO.java @@ -0,0 +1,54 @@ +package com.example.umc8th.dto; + +import lombok.Getter; + +public class OAuth2DTO { + + @Getter + public static class OAuth2TokenDTO { + String token_type; + String access_token; + String refresh_token; + Long expires_in; + Long refresh_token_expires_in; + String scope; + } + + @Getter + public static class KakaoProfile { + private Long id; + private String connected_at; + private Properties properties; + private KakaoAccount kakao_account; + + @Getter + public class Properties { + private String nickname; + private String profile_image; + private String thumbnail_image; + } + + @Getter + public class KakaoAccount { + private String email; + private Boolean is_email_verified; + private Boolean email_needs_agreement; + private Boolean has_email; + private Boolean profile_nickname_needs_agreement; + private Boolean profile_image_needs_agreement; + private Boolean email_needs_argument; + private Boolean is_email_valid; + private Profile profile; + + @Getter + public class Profile { + private String nickname; + private String thumbnail_image_url; + private String profile_image_url; + private Boolean is_default_nickname; + private Boolean is_default_image; + } + } + } +} + diff --git a/src/main/java/com/example/umc8th/entity/Member.java b/src/main/java/com/example/umc8th/entity/Member.java index 360b197..512119a 100644 --- a/src/main/java/com/example/umc8th/entity/Member.java +++ b/src/main/java/com/example/umc8th/entity/Member.java @@ -27,6 +27,12 @@ public class Member { @Column(name = "refresh_token", length = 1000) private String refreshToken; + @Column(name="email", unique = true, nullable = true) + private String email; + + @Column(name="role", unique = true, nullable = true) + private String role; + public void updateTokens(String accessToken, String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; diff --git a/src/main/java/com/example/umc8th/exception/MemberErrorCode.java b/src/main/java/com/example/umc8th/exception/MemberErrorCode.java index 80e7e16..a486d40 100644 --- a/src/main/java/com/example/umc8th/exception/MemberErrorCode.java +++ b/src/main/java/com/example/umc8th/exception/MemberErrorCode.java @@ -10,7 +10,10 @@ public enum MemberErrorCode implements BaseErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "멤버를 찾지 못했습니다."), - BAD_CREDENTIAL(HttpStatus.BAD_REQUEST, "MEMBER401", "유효하지 않는 토큰입니다."); + BAD_CREDENTIAL(HttpStatus.BAD_REQUEST, "MEMBER401", "유효하지 않는 토큰입니다."), + + OAUTH_TOKEN_FAIL(HttpStatus.MULTI_STATUS, "MEMBER2400", "OAuth 토큰을 변경하지 못했습니다."), + OAUTH_USER_INFO_FAIL(HttpStatus.MULTI_STATUS, "MEMBER2500", "사용자 정보를 가져오지 못했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/example/umc8th/global/config/JwtFilter.java b/src/main/java/com/example/umc8th/global/config/JwtFilter.java index 4b02994..2e1ff91 100644 --- a/src/main/java/com/example/umc8th/global/config/JwtFilter.java +++ b/src/main/java/com/example/umc8th/global/config/JwtFilter.java @@ -33,18 +33,18 @@ protected void doFilterInternal(HttpServletRequest request, if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { String token = authorizationHeader.substring(7); - if (!jwtUtil.isValid(token)) { - throw new JwtException("Invalid JWT token"); - } +// if (!jwtUtil.isValid(token)) { +// throw new JwtException("Invalid JWT token"); +// } String username = jwtUtil.getUsername(token); - if (username == null) { - throw new JwtException("Username is null in token"); - } +// if (username == null) { +// throw new JwtException("Username is null in token"); +// } UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); - UsernamePasswordAuthenticationToken authentication = + Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/src/main/java/com/example/umc8th/global/config/SecurityConfig.java b/src/main/java/com/example/umc8th/global/config/SecurityConfig.java index ae4f6f5..ea431f6 100644 --- a/src/main/java/com/example/umc8th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc8th/global/config/SecurityConfig.java @@ -5,6 +5,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.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -32,6 +33,7 @@ public class SecurityConfig { private String[] allowUrl = { "/auth/sign-up", "/auth/login", + "/auth/oauth2/**", "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", @@ -55,6 +57,10 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authenticationEntryPoint(customEntryPoint) .accessDeniedHandler(customAccessDeniedHandler) ) + + .formLogin(AbstractHttpConfigurer::disable) + .oauth2Login(Customizer.withDefaults()) + .addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class) /* // formLogin 설정 .formLogin(formLogin -> formLogin diff --git a/src/main/java/com/example/umc8th/repository/MemberRepository.java b/src/main/java/com/example/umc8th/repository/MemberRepository.java index bc5d45b..7da68e2 100644 --- a/src/main/java/com/example/umc8th/repository/MemberRepository.java +++ b/src/main/java/com/example/umc8th/repository/MemberRepository.java @@ -8,4 +8,5 @@ public interface MemberRepository extends JpaRepository { Optional findByUsername(String username); + Optional findByEmail(String email); } diff --git a/src/main/java/com/example/umc8th/service/command/TokenCommandService.java b/src/main/java/com/example/umc8th/service/command/TokenCommandService.java index 662d1d9..3f545b5 100644 --- a/src/main/java/com/example/umc8th/service/command/TokenCommandService.java +++ b/src/main/java/com/example/umc8th/service/command/TokenCommandService.java @@ -3,6 +3,9 @@ import com.example.umc8th.entity.Member; import com.example.umc8th.dto.MemberResponseDTO; +import java.util.List; + public interface TokenCommandService { MemberResponseDTO.LoginResponseDTO createLoginToken(Member member); + List createTokens(Member member); } diff --git a/src/main/java/com/example/umc8th/service/command/impl/TokenCommandServiceImpl.java b/src/main/java/com/example/umc8th/service/command/impl/TokenCommandServiceImpl.java index d9b87d5..2c81601 100644 --- a/src/main/java/com/example/umc8th/service/command/impl/TokenCommandServiceImpl.java +++ b/src/main/java/com/example/umc8th/service/command/impl/TokenCommandServiceImpl.java @@ -8,6 +8,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.List; + @Service @RequiredArgsConstructor public class TokenCommandServiceImpl implements TokenCommandService { @@ -31,4 +34,11 @@ public MemberResponseDTO.LoginResponseDTO createLoginToken(Member member) { .refreshToken(refreshToken) .build(); } + + public List createTokens(Member member){ + List result = new ArrayList<>(); + result.add(jwtUtil.createAccessToken(member)); + result.add(jwtUtil.createRefreshToken(member)); + return result; + } } diff --git a/src/main/java/com/example/umc8th/service/oauth/OAuth2Service.java b/src/main/java/com/example/umc8th/service/oauth/OAuth2Service.java new file mode 100644 index 0000000..926729b --- /dev/null +++ b/src/main/java/com/example/umc8th/service/oauth/OAuth2Service.java @@ -0,0 +1,7 @@ +package com.example.umc8th.service.oauth; + +import com.example.umc8th.dto.MemberResponseDTO; + +public interface OAuth2Service { + MemberResponseDTO.MemberTokenDTO login(String code); +} diff --git a/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java b/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java new file mode 100644 index 0000000..46f4649 --- /dev/null +++ b/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java @@ -0,0 +1,147 @@ +package com.example.umc8th.service.oauth; + +import com.example.umc8th.dto.MemberResponseDTO; +import com.example.umc8th.dto.OAuth2DTO; +import com.example.umc8th.entity.Member; +import com.example.umc8th.exception.MemberErrorCode; +import com.example.umc8th.exception.MemberException; +import com.example.umc8th.global.config.JwtUtil; +import com.example.umc8th.repository.MemberRepository; +import com.example.umc8th.service.command.TokenCommandService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +// OAuth2Service Impl +@Service +@RequiredArgsConstructor +@Slf4j +public class OAuth2ServiceImpl implements OAuth2Service{ + + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") + private String tokenURI; // Resource Server에 토큰 요청시 사용할 URI + + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private String userInfoURI; // 사용자 정보 가져올 때 사용할 URI + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; // API KEY + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirectURI; // 설정한 Redirect uri + + private final MemberRepository memberRepository; + private final TokenCommandService tokenCommandService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public MemberResponseDTO.MemberTokenDTO login(String code) { + + OAuth2DTO.OAuth2TokenDTO tokenDTO = getKakaoAccessToken(code); + + OAuth2DTO.KakaoProfile profile = getKakaoProfile(tokenDTO.getAccess_token()); + + Member member = processMemberRegistration(profile); + + return generateTokenResponse(member); + } + + private OAuth2DTO.OAuth2TokenDTO getKakaoAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", clientId); + body.add("redirect_uri", redirectURI); + body.add("code", code); + + ResponseEntity response = new RestTemplate().exchange( + tokenURI, + HttpMethod.POST, + new HttpEntity<>(body, headers), + String.class + ); + + return parseResponse(response, OAuth2DTO.OAuth2TokenDTO.class, MemberErrorCode.OAUTH_TOKEN_FAIL); + } + + private OAuth2DTO.KakaoProfile getKakaoProfile(String accessToken) { + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + accessToken); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + + ResponseEntity response = restTemplate.exchange( + userInfoURI, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + + return parseResponse(response, OAuth2DTO.KakaoProfile.class, MemberErrorCode.OAUTH_USER_INFO_FAIL); + } + + private T parseResponse(ResponseEntity response, Class valueType, MemberErrorCode errorCode) { + try { + return objectMapper.readValue(response.getBody(), valueType); + } catch (Exception e) { + throw new MemberException(errorCode); + } + } + + private Member processMemberRegistration(OAuth2DTO.KakaoProfile profile) { + String email = extractEmail(profile); + + return memberRepository.findByEmail(email) + .orElseGet(() -> registerNewMember(email)); + } + + private String extractEmail(OAuth2DTO.KakaoProfile profile) { + return Optional.ofNullable(profile.getKakao_account()) + .map(OAuth2DTO.KakaoProfile.KakaoAccount::getEmail) // email 그대로 사용 + .orElseThrow(() -> new MemberException(MemberErrorCode.OAUTH_TOKEN_FAIL)); + } + + private Member registerNewMember(String email) { + Member newMember = Member.builder() + .email(email) + .password(generateTemporaryPassword()) + .username(extractUsernameFromEmail(email)) + .role("ROLE_USER") + .build(); + + return memberRepository.save(newMember); + } + + private String generateTemporaryPassword() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 15); + } + + private String extractUsernameFromEmail(String email) { + return email.split("@")[0]; + } + + private MemberResponseDTO.MemberTokenDTO generateTokenResponse(Member member) { + List tokens = tokenCommandService.createTokens(member); + + return MemberResponseDTO.MemberTokenDTO.builder() + .accessToken(tokens.get(0)) + .refreshToken(tokens.get(1)) + .id(member.getId()) + .build(); + } +} \ No newline at end of file From 4012419b82a6fb760bd8ad29def5d052afc09052 Mon Sep 17 00:00:00 2001 From: hyoin Date: Tue, 13 May 2025 16:28:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20[feat]=20OAuth2=EC=97=90?= =?UTF-8?q?=EC=84=9C=20member=20=EC=83=9D=EC=84=B1=20=EC=A6=89=EC=8B=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=B0=9C=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/oauth/OAuth2ServiceImpl.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java b/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java index 46f4649..85b5de0 100644 --- a/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java +++ b/src/main/java/com/example/umc8th/service/oauth/OAuth2ServiceImpl.java @@ -117,14 +117,24 @@ private String extractEmail(OAuth2DTO.KakaoProfile profile) { } private Member registerNewMember(String email) { - Member newMember = Member.builder() + Member tempMember = Member.builder() .email(email) .password(generateTemporaryPassword()) .username(extractUsernameFromEmail(email)) .role("ROLE_USER") .build(); - return memberRepository.save(newMember); + List tokens = tokenCommandService.createTokens(tempMember); + Member member = Member.builder() + .email(tempMember.getEmail()) + .password(tempMember.getPassword()) + .username(tempMember.getUsername()) + .role(tempMember.getRole()) + .accessToken(tokens.get(0)) + .refreshToken(tokens.get(1)) + .build(); + + return memberRepository.save(member); } private String generateTemporaryPassword() { @@ -139,8 +149,8 @@ private MemberResponseDTO.MemberTokenDTO generateTokenResponse(Member member) { List tokens = tokenCommandService.createTokens(member); return MemberResponseDTO.MemberTokenDTO.builder() - .accessToken(tokens.get(0)) - .refreshToken(tokens.get(1)) + .accessToken(member.getAccessToken()) + .refreshToken(member.getRefreshToken()) .id(member.getId()) .build(); }