diff --git a/build.gradle b/build.gradle index ccca72b..8adc301 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,8 @@ dependencies { // AOP implementation 'org.springframework.boot:spring-boot-starter-aop' + + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { diff --git a/src/main/java/com/sayup/SayUp/config/SecurityConfig.java b/src/main/java/com/sayup/SayUp/config/SecurityConfig.java index 359deef..0fae831 100644 --- a/src/main/java/com/sayup/SayUp/config/SecurityConfig.java +++ b/src/main/java/com/sayup/SayUp/config/SecurityConfig.java @@ -37,7 +37,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti final String[] PUBLIC_URLS = { "/api/auth/**", // 인증 관련 API "/swagger-ui/**", // Swagger UI - "/v3/api-docs/**" // OpenAPI 문서 + "/v3/api-docs/**", // OpenAPI 문서 + "/callback/**" // 카카오 로그인 }; return http diff --git a/src/main/java/com/sayup/SayUp/controller/AuthController.java b/src/main/java/com/sayup/SayUp/controller/AuthController.java index 53c3756..583f4ae 100644 --- a/src/main/java/com/sayup/SayUp/controller/AuthController.java +++ b/src/main/java/com/sayup/SayUp/controller/AuthController.java @@ -4,19 +4,19 @@ import com.sayup.SayUp.dto.AuthResponseDTO; import com.sayup.SayUp.service.AuthService; import jakarta.validation.Valid; +import lombok.AllArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @RestController @RequestMapping("/api/auth") +@AllArgsConstructor public class AuthController { private final AuthService authService; - public AuthController(AuthService authService) { - this.authService = authService; - } - /** * 사용자 회원가입 처리 * @param authRequestDTO 사용자 이메일 및 비밀번호 정보 diff --git a/src/main/java/com/sayup/SayUp/entity/User.java b/src/main/java/com/sayup/SayUp/entity/User.java index a54a388..2e6d6e1 100644 --- a/src/main/java/com/sayup/SayUp/entity/User.java +++ b/src/main/java/com/sayup/SayUp/entity/User.java @@ -1,6 +1,7 @@ package com.sayup.SayUp.entity; import jakarta.persistence.*; +import lombok.Builder; import lombok.Getter; import lombok.Setter; @@ -13,6 +14,9 @@ @Getter @Setter public class User { + public User() { + } + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; @@ -31,6 +35,8 @@ public class User { @Column(nullable = false, updatable = false) private LocalDateTime createdAt = LocalDateTime.now(); + private String role; + @Override public boolean equals(Object obj) { if (this == obj) return true; diff --git a/src/main/java/com/sayup/SayUp/kakao/controller/KakaoLoginController.java b/src/main/java/com/sayup/SayUp/kakao/controller/KakaoLoginController.java new file mode 100644 index 0000000..30bd7f9 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/controller/KakaoLoginController.java @@ -0,0 +1,44 @@ +package com.sayup.SayUp.kakao.controller; + +import com.sayup.SayUp.dto.AuthResponseDTO; +import com.sayup.SayUp.kakao.dto.KakaoUserInfoResponseDto; +import com.sayup.SayUp.kakao.service.KakaoService; +import com.sayup.SayUp.security.JwtTokenProvider; +import com.sayup.SayUp.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("") +public class KakaoLoginController { + + private final KakaoService kakaoService; + private final JwtTokenProvider jwtTokenProvider; + private final AuthService authService; + + @GetMapping("/callback") + public ResponseEntity callback(@RequestParam("code") String code) throws IOException { + String accessToken = kakaoService.getAccessTokenFromKakao(code); + + KakaoUserInfoResponseDto userInfo = kakaoService.getUserInfo(accessToken); + + String email = null; + String jwt = null; + if (userInfo.getKakaoAccount() != null) { + email = userInfo.getKakaoAccount().getEmail(); + authService.loadOrCreateUser(email); + jwt = jwtTokenProvider.createTokenFromEmail(email); + } + + return ResponseEntity.ok(new AuthResponseDTO(jwt, email)); + } +} diff --git a/src/main/java/com/sayup/SayUp/kakao/controller/KakaoLoginPageController.java b/src/main/java/com/sayup/SayUp/kakao/controller/KakaoLoginPageController.java new file mode 100644 index 0000000..d1c5d6c --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/controller/KakaoLoginPageController.java @@ -0,0 +1,26 @@ +package com.sayup.SayUp.kakao.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/login") +public class KakaoLoginPageController { + + @Value("${kakao.client_id}") + private String client_id; + + @Value("${kakao.redirect_uri}") + private String redirect_uri; + + @GetMapping("/page") + public String loginPage(Model model) { + String location = "https://kauth.kakao.com/oauth/authorize?response_type=code&client_id="+client_id+"&redirect_uri="+redirect_uri; + model.addAttribute("location", location); + + return "login"; + } +} diff --git a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java new file mode 100644 index 0000000..076d305 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoLoginDto.java @@ -0,0 +1,14 @@ +package com.sayup.SayUp.kakao.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Builder +@Getter +public class KakaoLoginDto { + + public String accessToken; + public String refreshToken; +} diff --git a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java new file mode 100644 index 0000000..8ad2636 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoTokenResponseDto.java @@ -0,0 +1,27 @@ +package com.sayup.SayUp.kakao.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor //역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoTokenResponseDto { + + @JsonProperty("token_type") + public String tokenType; + @JsonProperty("access_token") + public String accessToken; + @JsonProperty("id_token") + public String idToken; + @JsonProperty("expires_in") + public Integer expiresIn; + @JsonProperty("refresh_token") + public String refreshToken; + @JsonProperty("refresh_token_expires_in") + public Integer refreshTokenExpiresIn; + @JsonProperty("scope") + public String scope; +} \ No newline at end of file diff --git a/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java new file mode 100644 index 0000000..2cf7978 --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/dto/KakaoUserInfoResponseDto.java @@ -0,0 +1,190 @@ +package com.sayup.SayUp.kakao.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.util.HashMap; + +@Getter +@NoArgsConstructor //역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoUserInfoResponseDto { + + //회원 번호 + @JsonProperty("id") + public Long id; + + //자동 연결 설정을 비활성화한 경우만 존재. + //true : 연결 상태, false : 연결 대기 상태 + @JsonProperty("has_signed_up") + public Boolean hasSignedUp; + + //서비스에 연결 완료된 시각. UTC + @JsonProperty("connected_at") + public Date connectedAt; + + //카카오싱크 간편가입을 통해 로그인한 시각. UTC + @JsonProperty("synched_at") + public Date synchedAt; + + //사용자 프로퍼티 + @JsonProperty("properties") + public HashMap properties; + + //카카오 계정 정보 + @JsonProperty("kakao_account") + public KakaoAccount kakaoAccount; + + //uuid 등 추가 정보 + @JsonProperty("for_partner") + public Partner partner; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class KakaoAccount { + + //프로필 정보 제공 동의 여부 + @JsonProperty("profile_needs_agreement") + public Boolean isProfileAgree; + + //닉네임 제공 동의 여부 + @JsonProperty("profile_nickname_needs_agreement") + public Boolean isNickNameAgree; + + //프로필 사진 제공 동의 여부 + @JsonProperty("profile_image_needs_agreement") + public Boolean isProfileImageAgree; + + //사용자 프로필 정보 + @JsonProperty("profile") + public Profile profile; + + //이름 제공 동의 여부 + @JsonProperty("name_needs_agreement") + public Boolean isNameAgree; + + //카카오계정 이름 + @JsonProperty("name") + public String name; + + //이메일 제공 동의 여부 + @JsonProperty("email_needs_agreement") + public Boolean isEmailAgree; + + //이메일이 유효 여부 + // true : 유효한 이메일, false : 이메일이 다른 카카오 계정에 사용돼 만료 + @JsonProperty("is_email_valid") + public Boolean isEmailValid; + + //이메일이 인증 여부 + //true : 인증된 이메일, false : 인증되지 않은 이메일 + @JsonProperty("is_email_verified") + public Boolean isEmailVerified; + + //카카오계정 대표 이메일 + @JsonProperty("email") + public String email; + + //연령대 제공 동의 여부 + @JsonProperty("age_range_needs_agreement") + public Boolean isAgeAgree; + + //연령대 + //참고 https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info + @JsonProperty("age_range") + public String ageRange; + + //출생 연도 제공 동의 여부 + @JsonProperty("birthyear_needs_agreement") + public Boolean isBirthYearAgree; + + //출생 연도 (YYYY 형식) + @JsonProperty("birthyear") + public String birthYear; + + //생일 제공 동의 여부 + @JsonProperty("birthday_needs_agreement") + public Boolean isBirthDayAgree; + + //생일 (MMDD 형식) + @JsonProperty("birthday") + public String birthDay; + + //생일 타입 + // SOLAR(양력) 혹은 LUNAR(음력) + @JsonProperty("birthday_type") + public String birthDayType; + + //성별 제공 동의 여부 + @JsonProperty("gender_needs_agreement") + public Boolean isGenderAgree; + + //성별 + @JsonProperty("gender") + public String gender; + + //전화번호 제공 동의 여부 + @JsonProperty("phone_number_needs_agreement") + public Boolean isPhoneNumberAgree; + + //전화번호 + //국내 번호인 경우 +82 00-0000-0000 형식 + @JsonProperty("phone_number") + public String phoneNumber; + + //CI 동의 여부 + @JsonProperty("ci_needs_agreement") + public Boolean isCIAgree; + + //CI, 연계 정보 + @JsonProperty("ci") + public String ci; + + //CI 발급 시각, UTC + @JsonProperty("ci_authenticated_at") + public Date ciCreatedAt; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class Profile { + + //닉네임 + @JsonProperty("nickname") + public String nickName; + + //프로필 미리보기 이미지 URL + @JsonProperty("thumbnail_image_url") + public String thumbnailImageUrl; + + //프로필 사진 URL + @JsonProperty("profile_image_url") + public String profileImageUrl; + + //프로필 사진 URL 기본 프로필인지 여부 + //true : 기본 프로필, false : 사용자 등록 + @JsonProperty("is_default_image") + public String isDefaultImage; + + //닉네임이 기본 닉네임인지 여부 + //true : 기본 닉네임, false : 사용자 등록 + @JsonProperty("is_default_nickname") + public Boolean isDefaultNickName; + + } + } + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public class Partner { + //고유 ID + @JsonProperty("uuid") + public String uuid; + } + +} \ No newline at end of file diff --git a/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java b/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java new file mode 100644 index 0000000..61ec55e --- /dev/null +++ b/src/main/java/com/sayup/SayUp/kakao/service/KakaoService.java @@ -0,0 +1,87 @@ +package com.sayup.SayUp.kakao.service; + +import com.sayup.SayUp.kakao.dto.KakaoTokenResponseDto; +import com.sayup.SayUp.kakao.dto.KakaoUserInfoResponseDto; +import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +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.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + + +@Slf4j +@RequiredArgsConstructor +@Service +public class KakaoService { + + private String clientId; + private final String KAUTH_TOKEN_URL_HOST ; + private final String KAUTH_USER_URL_HOST; + + @Autowired + public KakaoService(@Value("${kakao.client_id}") String clientId) { + this.clientId = clientId; + KAUTH_TOKEN_URL_HOST ="https://kauth.kakao.com"; + KAUTH_USER_URL_HOST = "https://kapi.kakao.com"; + } + + public String getAccessTokenFromKakao(String code) { + + KakaoTokenResponseDto kakaoTokenResponseDto = WebClient.create(KAUTH_TOKEN_URL_HOST).post() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .path("/oauth/token") + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", clientId) + .queryParam("code", code) + .build(true)) + .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + //TODO : Custom Exception + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoTokenResponseDto.class) + .block(); + + + log.info(" [Kakao Service] Access Token ------> {}", kakaoTokenResponseDto.getAccessToken()); + log.info(" [Kakao Service] Refresh Token ------> {}", kakaoTokenResponseDto.getRefreshToken()); + //제공 조건: OpenID Connect가 활성화 된 앱의 토큰 발급 요청인 경우 또는 scope에 openid를 포함한 추가 항목 동의 받기 요청을 거친 토큰 발급 요청인 경우 + log.info(" [Kakao Service] Id Token ------> {}", kakaoTokenResponseDto.getIdToken()); + log.info(" [Kakao Service] Scope ------> {}", kakaoTokenResponseDto.getScope()); + + return kakaoTokenResponseDto.getAccessToken(); + } + + + + + public KakaoUserInfoResponseDto getUserInfo(String accessToken) { + + KakaoUserInfoResponseDto userInfo = WebClient.create(KAUTH_USER_URL_HOST) + .get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .path("/v2/user/me") + .build(true)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) // access token 인가 + .header(HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + //TODO : Custom Exception + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus(HttpStatusCode::is5xxServerError, clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(KakaoUserInfoResponseDto.class) + .block(); + + log.info("[ Kakao Service ] Auth ID ---> {} ", userInfo.getId()); + log.info("[ Kakao Service ] NickName ---> {} ", userInfo.getKakaoAccount().getProfile().getNickName()); + log.info("[ Kakao Service ] ProfileImageUrl ---> {} ", userInfo.getKakaoAccount().getProfile().getProfileImageUrl()); + + return userInfo; + } +} diff --git a/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java b/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java index 003b6c9..ccc644e 100644 --- a/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java +++ b/src/main/java/com/sayup/SayUp/security/JwtTokenProvider.java @@ -52,6 +52,19 @@ public String createToken(Authentication authentication) { .compact(); } + public String createTokenFromEmail(String email) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setSubject(email) // 이메일을 subject로 설정 + .claim("roles", "ROLE_USER") // 기본 권한 설정 (필요하면 변경) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + /** * JWT 토큰 유효성 검증 * @param token 검증할 JWT 토큰 diff --git a/src/main/java/com/sayup/SayUp/service/AuthService.java b/src/main/java/com/sayup/SayUp/service/AuthService.java index a3bc369..46094cd 100644 --- a/src/main/java/com/sayup/SayUp/service/AuthService.java +++ b/src/main/java/com/sayup/SayUp/service/AuthService.java @@ -66,6 +66,15 @@ public void register(AuthRequestDTO authRequestDTO) { logger.info("User registered successfully with email: {}", authRequestDTO.getEmail()); } + // 카카오 로그인 시 사용자 자동 등록 + public void loadOrCreateUser(String email) { + User user = new User(); + user.setEmail(email); + user.setPassword(passwordEncoder.encode("kakao_user")); // // OAuth 사용자는 비밀번호를 따로 설정하지 않음 + + userRepository.save(user); + } + /** * Spring Security UserDetailsService 구현 * @param email 사용자 이메일 @@ -107,6 +116,8 @@ public AuthResponseDTO login(AuthRequestDTO authRequestDTO) { return new AuthResponseDTO(jwt, authRequestDTO.getEmail()); } + + /** * 토큰을 블랙리스트에 추가 * @param token 무효화할 토큰 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 745ebdb..803c755 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -39,3 +39,6 @@ springdoc.swagger-ui.operations-sorter=alpha # API ?? ?? springdoc.packages-to-scan=com.sayup.SayUp.controller + +kakao.client_id=${KAKAO} +kakao.redirect_uri=http://localhost:8080/callback diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..bdacabc --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,15 @@ + + + + + KakaoLogin + + +
+

카카오 로그인

+ + + +
+ + \ No newline at end of file