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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.hibernate.validator:hibernate-validator'

// swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
Expand All @@ -43,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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.example.umc8th.domain.member.dto.response.MemberResponseDTO;
import com.example.umc8th.domain.member.entity.Member;
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.apiPayload.success.GeneralSuccessCode;
import lombok.RequiredArgsConstructor;
Expand All @@ -15,10 +16,12 @@
public class MemberController {

private final MemberCommandService memberCommandService;
private final OAuth2Service oAuth2Service;

@PostMapping("/sign-up")
public CustomResponse<MemberResponseDTO.SignUpResponseDTO> signUp(@RequestBody MemberRequestDTO.SignUpRequestDTO dto) {
Member member = memberCommandService.signUp(dto);

return CustomResponse.onSuccess(GeneralSuccessCode.OK, MemberResponseDTO.SignUpResponseDTO.from(member));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.example.umc8th.domain.member.controller;

import com.example.umc8th.domain.member.dto.response.MemberResponseDTO;
import com.example.umc8th.domain.member.service.command.OAuth2Service;
import com.example.umc8th.global.apiPayload.CustomResponse;
import com.example.umc8th.global.apiPayload.success.GeneralSuccessCode;
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<MemberResponseDTO.MemberTokenDTO> loginWithKakao(@RequestParam("code") String code) {
MemberResponseDTO.MemberTokenDTO tokenDTO = oAuth2Service.login(code);

return CustomResponse.onSuccess(GeneralSuccessCode.OK, tokenDTO); // 표준 응답 포맷으로 반환
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,10 @@ public static LoginResponseDTO from(Member member) {
.build();
}
}

@Builder
public record MemberTokenDTO (
String accessToken,
String refreshToken
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.example.umc8th.domain.member.dto.response;

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;
}
}
}
}

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.umc8th.domain.member.entity;

import com.example.umc8th.domain.member.enums.Role;
import jakarta.persistence.*;
import lombok.*;

Expand All @@ -15,6 +16,12 @@ public class Member {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "email")
private String email;

@Enumerated(EnumType.STRING)
private Role role;

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.example.umc8th.domain.member.enums;

public enum Role {
ADMIN, USER
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
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.BAD_REQUEST, "MEMBER4001", "토큰 변환 실패"),
OAUTH_EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4002", "이메일 정보를 찾을 수 없습니다."),
OAUTH_LOGIN_FAIL(HttpStatus.UNAUTHORIZED, "MEMBER4011", "로그인에 실패하였습니다."),
OAUTH_USER_INFO_FAIL(HttpStatus.NOT_FOUND, "MEMBER4004", "사용자 정보를 가져오는데 실패하였습니다.");

private final HttpStatus status;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@

public interface MemberRepository extends JpaRepository<Member, String> {
Optional<Member> findByUsername(String username);
Optional<Member> findByEmail(String email);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.example.umc8th.domain.member.service.command;

import com.example.umc8th.domain.member.dto.response.MemberResponseDTO;

public interface OAuth2Service {
MemberResponseDTO.MemberTokenDTO login(String code);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.example.umc8th.domain.member.service.command;

import com.example.umc8th.domain.member.dto.response.MemberResponseDTO;
import com.example.umc8th.domain.member.dto.response.OAuth2DTO;
import com.example.umc8th.domain.member.entity.Member;
import com.example.umc8th.domain.member.enums.Role;
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.config.JwtUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
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 JwtUtil jwtProvider;

@Override
public MemberResponseDTO.MemberTokenDTO login(String code) {
// 인가코드 토큰 가져오기
RestTemplate restTemplate = new RestTemplate(); // 요청을 보내기 위한 RestTemplate
HttpHeaders httpHeaders = new HttpHeaders(); // 헤더 선언

httpHeaders.add("Content-Type", "application/x-www-form-urlencoded"); // 헤더 설정

MultiValueMap<String, String> map = new LinkedMultiValueMap<>(); // RequestBody 설정
map.add("grant_type", "authorization_code");
map.add("client_id", clientId);
map.add("redirect_uri", redirectURI);
map.add("code", code);
HttpEntity<MultiValueMap> request = new HttpEntity<>(map, httpHeaders); // Header와 Body를 이용하여 요청에 보낼 HttpEntity 생성

// 요청을 보내서 응답 받아오기
ResponseEntity<String> response1 = restTemplate.exchange(
tokenURI, // URI
HttpMethod.POST, // Method
request, // Request 내용
String.class); // 받을 응답 자료형

ObjectMapper objectMapper = new ObjectMapper(); // String을 OAuth2DTO.OAuth2TokenDTO로 변경하기 위해 ObjectMapper 선언
OAuth2DTO.OAuth2TokenDTO oAuth2TokenDTO = null;

try {
oAuth2TokenDTO = objectMapper.readValue(response1.getBody(), OAuth2DTO.OAuth2TokenDTO.class);
} catch (Exception e) {
throw new MemberException(MemberErrorCode.OAUTH_TOKEN_FAIL); // 토큰 DTO로 변경하지 못한 경우 Exception 보냄
}

// 토큰으로 정보 가져오기
// 위와 흐름이 동일하여 생략하겠습니다. 아래는 RequestBody가 없어서 추가하지 않은 것을 볼 수 있습니다.
restTemplate = new RestTemplate();
httpHeaders = new HttpHeaders();

httpHeaders.add("Authorization", "Bearer " + oAuth2TokenDTO.getAccess_token());
httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8");

HttpEntity<MultiValueMap> request1 = new HttpEntity<>(httpHeaders);

ResponseEntity<String> response2 = restTemplate.exchange(
userInfoURI,
HttpMethod.GET,
request1,
String.class
);

OAuth2DTO.KakaoProfile profile = null;
ObjectMapper om = new ObjectMapper();

try {
profile = om.readValue(response2.getBody(), OAuth2DTO.KakaoProfile.class);
} catch(Exception e) {
throw new MemberException(MemberErrorCode.OAUTH_USER_INFO_FAIL); // 사용자 정보를 가져오지 못한 경우 Exception 발생
}

// 회원가입이 되었으면 사용자 로그인 안되어있으면 회원가입 후 로그인
String email = profile.getKakao_account().getEmail(); // Kakao에서의 Id를 가지고 Email로 변경

// email을 찾고 있으면 member에 넣고 없으면 새로 만들어서 저장하고 넣는다.
Member member = memberRepository.findByEmail(email).orElse(
memberRepository.save(Member.builder()
.email(email)
.role(Role.USER)
.build())
);

// TokenDTO로 변경해서 저번 주차에 구현한 JWT 형태로 반환
return MemberResponseDTO.MemberTokenDTO.builder()
.accessToken(jwtProvider.createAccessToken(member))
.refreshToken(jwtProvider.createRefreshToken(member))
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ private String createToken(Member member, Duration expiration) {
Instant now = Instant.now();
return Jwts.builder()
.subject(member.getUsername()) // Subject를 Username으로 설정
.claim("id", member.getId()) // claim으로 내용 추가
.claim("email", member.getEmail()) // claim으로 내용 추가
.issuedAt(Date.from(now)) // 언제 발급한지
.expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지
.signWith(secretKey) // sign할 Key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class SecurityConfig {
private String[] allowUrl = {
"/auth/sign-up",
"/auth/login",
"/oauth2/**",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**",
Expand All @@ -48,6 +49,8 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.csrf(AbstractHttpConfigurer::disable)
// http basic 인증 방식 비활성화
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.oauth2Login(Customizer.withDefaults())
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exception -> exception
.authenticationEntryPoint(customEntryPoint)
Expand Down