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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ jobs:
TMAP_API_KEY: ${{ secrets.TMAP_API_KEY }}
KAKAOMAP_API_KEY: ${{ secrets.KAKAOMAP_API_KEY }}


KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}
KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }}

steps:
- name: Checkout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,18 @@ public RsData<?> refresh() {
String newAccessToken = tokenService.reissueAccessToken();
return RsData.success("토큰 재발급 완료", newAccessToken);
}

@Operation(summary = "카카오 소셜 로그인", description = "카카오 OAuth 인가 코드를 이용해 로그인/회원가입을 진행합니다.")
@GetMapping("/login/kakao")
public RsData<?> kakaoLogin(@RequestParam String code) {
LoginResponse response = authService.kakaoLogin(code);
return RsData.success("카카오 로그인 성공", response);
}

@Operation(summary = "구글 소셜 로그인", description = "구글 OAuth 인가 코드를 이용해 로그인/회원가입을 진행합니다.")
@GetMapping("/login/google")
public RsData<?> googleLogin(@RequestParam String code) {
LoginResponse response = authService.googleLogin(code);
return RsData.success("구글 로그인 성공", response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.back.web7_9_codecrete_be.domain.auth.dto.kakao;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

@Getter
public class KakaoTokenResponse {

@JsonProperty("access_token")
private String accessToken;

@JsonProperty("expires_in")
private int expiresIn;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.back.web7_9_codecrete_be.domain.auth.dto.kakao;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class KakaoUserInfo {
private String socialId;
private String email;
private String nickname;
private String profileImageUrl;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.back.web7_9_codecrete_be.domain.auth.dto.kakao;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

@Getter
public class KakaoUserResponse {

private Long id;

@JsonProperty("kakao_account")
private KakaoAccount kakaoAccount;

public KakaoUserInfo toUserInfo() {
return new KakaoUserInfo(
String.valueOf(id),
kakaoAccount.getEmail(),
kakaoAccount.getProfile().getNickname(),
kakaoAccount.getProfile().getProfileImageUrl()
);
}

@Getter
public static class KakaoAccount {
private String email;
private Profile profile;
}

@Getter
public static class Profile {
private String nickname;

@JsonProperty("profile_image_url")
private String profileImageUrl;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.back.web7_9_codecrete_be.domain.auth.service;

import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserInfo;
import com.back.web7_9_codecrete_be.domain.auth.dto.request.LoginRequest;
import com.back.web7_9_codecrete_be.domain.auth.dto.request.SignupRequest;
import com.back.web7_9_codecrete_be.domain.auth.dto.response.LoginResponse;
import com.back.web7_9_codecrete_be.domain.email.service.EmailService;
import com.back.web7_9_codecrete_be.domain.users.entity.SocialType;
import com.back.web7_9_codecrete_be.domain.users.entity.User;
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode;
Expand All @@ -25,6 +27,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
private final TokenService tokenService;
private final KakaoOAuthService kakaoOAuthService;

// 회원가입
public void signUp(SignupRequest req) {
Expand All @@ -50,6 +53,8 @@ public void signUp(SignupRequest req) {
.password(passwordEncoder.encode(req.getPassword()))
.birth(LocalDate.parse(req.getBirth()))
.profileImage(req.getProfileImage())
.socialType(SocialType.LOCAL)
.socialId(null)
.build();

userRepository.save(user);
Expand All @@ -66,6 +71,10 @@ public LoginResponse login(LoginRequest req) {
throw new BusinessException(UserErrorCode.USER_DELETED);
}

if (user.getSocialType() != SocialType.LOCAL) {
throw new BusinessException(AuthErrorCode.SOCIAL_USER_CANNOT_LOGIN);
}

if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
throw new BusinessException(AuthErrorCode.INVALID_PASSWORD);
}
Expand Down Expand Up @@ -119,4 +128,61 @@ private String generateTempPassword() {
}
return sb.toString();
}

@Transactional
public LoginResponse kakaoLogin(String code) {

// 1. 인가 코드 → 카카오 Access Token
String kakaoAccessToken = kakaoOAuthService.getAccessToken(code);

// 2. Access Token → 사용자 정보
KakaoUserInfo kakaoUserInfo = kakaoOAuthService.getUserInfo(kakaoAccessToken);

if (kakaoUserInfo.getEmail() == null) {
throw new BusinessException(AuthErrorCode.SOCIAL_EMAIL_NOT_PROVIDED);
}

// 3. 소셜 ID 기준 사용자 조회
User user = userRepository
.findBySocialTypeAndSocialId(
SocialType.KAKAO,
kakaoUserInfo.getSocialId()
)
.orElseGet(() -> registerKakaoUser(kakaoUserInfo));

// 4. 탈퇴 사용자 체크
if (user.getIsDeleted()) {
throw new BusinessException(UserErrorCode.USER_DELETED);
}

// 5. 토큰 발급
tokenService.issueTokens(user);

return new LoginResponse(user.getId(), user.getNickname());
}

private User registerKakaoUser(KakaoUserInfo info) {

String nickname = info.getNickname();
if (userRepository.existsByNickname(nickname)) {
nickname = nickname + "_" + System.currentTimeMillis();
}

User user = User.builder()
.email(info.getEmail())
.nickname(nickname)
.password(null)
.birth(null)
.profileImage(info.getProfileImageUrl())
.socialType(SocialType.KAKAO)
.socialId(info.getSocialId())
.build();

return userRepository.save(user);
}

public LoginResponse googleLogin(String code) {
// TODO: 구글 인가 코드 → 사용자 정보 → 로그인 처리
throw new UnsupportedOperationException("구글 로그인 미구현");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.back.web7_9_codecrete_be.domain.auth.service;

import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoTokenResponse;
import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserInfo;
import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
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 KakaoOAuthService {

@Value("${oauth.kakao.client-id}")
private String clientId;

@Value("${oauth.kakao.redirect-uri}")
private String redirectUri;

@Value("${oauth.kakao.client-secret}")
private String clientSecret;

private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token";
private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me";

private final RestTemplate restTemplate = new RestTemplate();

// 인가 코드 → 액세스 토큰
public String getAccessToken(String code) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
params.add("redirect_uri", redirectUri);
params.add("code", code);

HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);

ResponseEntity<KakaoTokenResponse> response =
restTemplate.postForEntity(
TOKEN_URL,
request,
KakaoTokenResponse.class
);

return response.getBody().getAccessToken();
}

// 인가 코드 → 카카오 사용자 정보
public KakaoUserInfo getUserInfo(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

HttpEntity<Void> request = new HttpEntity<>(headers);

ResponseEntity<KakaoUserResponse> response =
restTemplate.exchange(
USER_INFO_URL,
HttpMethod.GET,
request,
KakaoUserResponse.class
);

return response.getBody().toUserInfo();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.back.web7_9_codecrete_be.domain.users.entity;

public enum SocialType {
LOCAL, // 일반 회원
KAKAO, // 카카오
GOOGLE // 구글
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ public class User {
@Column(nullable = false, unique = true, length = 20)
private String nickname;

@Column(nullable = false, length = 100)
// 소셜 로그인 사용자는 password가 없을 수 있으므로 nullable = true
@Column(length = 100)
private String password;

@Column(nullable = false)
// 소셜 로그인 시 생년월일을 불러오지 못하므로 nullable = true
@Column(nullable = true)
private LocalDate birth;

@Column(name = "profile_image", length = 255)
Expand Down Expand Up @@ -59,18 +61,30 @@ public class User {
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted;

// 소셜 로그인용 컬럼 추가
@Enumerated(EnumType.STRING)
@Column(name = "social_type", nullable = false, length = 20)
private SocialType socialType;

@Column(name = "social_id", length = 100)
private String socialId;

@Builder
public User(String email,
String nickname,
String password,
LocalDate birth,
String profileImage) {
String profileImage,
SocialType socialType,
String socialId) {

this.email = email;
this.nickname = nickname;
this.password = password;
this.birth = birth;
this.profileImage = profileImage;
this.socialType = socialType;
this.socialId = socialId;

// 기본값 세팅
this.role = Role.USER;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.web7_9_codecrete_be.domain.users.repository;

import com.back.web7_9_codecrete_be.domain.users.entity.SocialType;
import com.back.web7_9_codecrete_be.domain.users.entity.User;
import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -19,4 +20,18 @@ List<User> findByIsDeletedTrueAndStatusAndDeletedDateBefore(
LocalDateTime time
);
Optional<User> findByEmailAndIsDeletedTrue(String email);

// 소셜 로그인 관련 추가 메서드

// 소셜 로그인용: 소셜 타입 + 소셜 ID 조회
Optional<User> findBySocialTypeAndSocialId(
SocialType socialType,
String socialId
);

// 소셜 회원가입용: 이메일 + 소셜 타입 조회
Optional<User> findByEmailAndSocialType(
String email,
SocialType socialType
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public enum AuthErrorCode implements ErrorCode {
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "A-110", "존재하지 않는 이메일입니다."),
INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "A-111", "비밀번호가 일치하지 않습니다."),
USER_INACTIVE(HttpStatus.FORBIDDEN, "A-112", "현재 비활성화된 계정입니다."),
SOCIAL_USER_CANNOT_LOGIN(HttpStatus.BAD_REQUEST, "A-113", "소셜 로그인 계정은 일반 로그인으로 로그인할 수 없습니다."),
SOCIAL_EMAIL_NOT_PROVIDED(HttpStatus.BAD_REQUEST, "A-114", "소셜 계정에서 이메일 정보를 제공하지 않았습니다."),

// 권한 관련
UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "A-120", "로그인이 필요합니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.back.web7_9_codecrete_be.global.initData;

import com.back.web7_9_codecrete_be.domain.users.entity.SocialType;
import com.back.web7_9_codecrete_be.domain.users.entity.User;
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
import jakarta.annotation.PostConstruct;
Expand Down Expand Up @@ -35,6 +36,8 @@ private void createTestUser() {
.password(passwordEncoder.encode("test1234!"))
.birth(LocalDate.of(1999, 1, 1))
.profileImage("https://example.com/profile.jpg")
.socialType(SocialType.LOCAL)
.socialId(null)
.build();

userRepository.save(testUser);
Expand All @@ -51,6 +54,8 @@ private void createAdminUser() {
.password(passwordEncoder.encode("admin1234!"))
.birth(LocalDate.of(1990, 1, 1))
.profileImage("https://example.com/profile.jpg")
.socialType(SocialType.LOCAL)
.socialId(null)
.build();

// dev 전용 어드민 권한 부여
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public AuthenticationManager authenticationManager(AuthenticationConfiguration c
public UrlBasedCorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration =new CorsConfiguration();

configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedOrigins(List.of("http://localhost:3000","https://web-6-7-codecrete-fe.vercel.app", "https://www.naeconcertbutakhae.shop"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));

configuration.setAllowedHeaders(List.of("*"));
Expand Down
Loading