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
3 changes: 2 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ dependencies {
// spring starter
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

//security
Expand Down Expand Up @@ -64,6 +63,8 @@ dependencies {
// sms
implementation 'com.solapi:sdk:1.0.3'

// google login
implementation 'com.google.api-client:google-api-client:2.7.2'
// firebase
implementation 'com.google.firebase:firebase-admin:9.1.1'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package ita.tinybite.domain.auth.controller;

import ita.tinybite.domain.auth.dto.request.KakaoLoginRequest;
import ita.tinybite.domain.auth.dto.request.KakaoSignupRequest;
import ita.tinybite.domain.auth.dto.request.RefreshTokenRequest;
import ita.tinybite.domain.auth.dto.request.*;
import ita.tinybite.domain.auth.dto.response.AuthResponse;
import ita.tinybite.domain.auth.service.AuthService;
import ita.tinybite.global.response.APIResponse;
Expand All @@ -18,6 +16,8 @@
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import static ita.tinybite.global.response.APIResponse.success;

@Slf4j
@RestController
@RequestMapping("/api/v1/auth")
Expand Down Expand Up @@ -55,31 +55,63 @@ public ResponseEntity<APIResponse<AuthResponse>> kakaoSignup(
) {
AuthResponse response = authService.kakaoSignup(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(APIResponse.success(response));
.body(success(response));
}

@PostMapping("/kakao/login")
public ResponseEntity<APIResponse<AuthResponse>> kakaoLogin(
@Valid @RequestBody KakaoLoginRequest request
) {
AuthResponse response = authService.kakaoLogin(request);
return ResponseEntity.ok(APIResponse.success(response));
return ResponseEntity.ok(success(response));
}

@PostMapping("/google/signup")
public ResponseEntity<APIResponse<AuthResponse>> googleSignup(
@Valid @RequestBody GoogleAndAppleSignupRequest req
) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(success(authService.googleSignup(req)));
}

@PostMapping("/google/login")
public APIResponse<AuthResponse> googleLogin(
@Valid @RequestBody GoogleAndAppleLoginReq req
) {
return success(authService.googleLogin(req));
}

@PostMapping("/apple/signup")
public ResponseEntity<APIResponse<AuthResponse>> appleSignup(
@Valid @RequestBody GoogleAndAppleSignupRequest req
) {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(success(authService.appleSignup(req)));
}

@PostMapping("/apple/login")
public APIResponse<AuthResponse> appleLogin(
@Valid @RequestBody GoogleAndAppleLoginReq req
) {
return success(authService.appleLogin(req));
}

@PostMapping("/refresh")
public ResponseEntity<APIResponse<AuthResponse>> refreshToken(
@Valid @RequestBody RefreshTokenRequest request
) {
AuthResponse response = authService.refreshToken(request);
return ResponseEntity.ok(APIResponse.success(response));
return ResponseEntity.ok(success(response));
}

@PostMapping("/logout")
public ResponseEntity<APIResponse<Void>> logout(
@RequestAttribute("userId") Long userId
) {
authService.logout(userId);
return ResponseEntity.ok(APIResponse.success(null));
return ResponseEntity.ok(success(null));
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package ita.tinybite.domain.auth.dto.request;

import ita.tinybite.domain.user.constant.PlatformType;

public record GoogleAndAppleLoginReq(String idToken,
PlatformType platformType) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ita.tinybite.domain.auth.dto.request;

import ita.tinybite.domain.user.constant.PlatformType;
import jakarta.validation.constraints.NotBlank;

public record GoogleAndAppleSignupRequest(
@NotBlank(message = "idToken은 필수입니다")
String idToken,
@NotBlank(message = "전화번호는 필수입니다")
String phone,
@NotBlank(message = "닉네임은 필수입니다")
String nickname,
@NotBlank(message = "위치 정보 필수입니다")
String location,
@NotBlank(message = "플랫폼정보는 필수입니다")
PlatformType platform
) {
}
154 changes: 150 additions & 4 deletions src/main/java/ita/tinybite/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package ita.tinybite.domain.auth.service;

import ita.tinybite.domain.auth.dto.request.KakaoLoginRequest;
import ita.tinybite.domain.auth.dto.request.KakaoSignupRequest;
import ita.tinybite.domain.auth.dto.request.RefreshTokenRequest;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory;
import ita.tinybite.domain.auth.dto.request.*;
import ita.tinybite.domain.auth.dto.response.AuthResponse;
import ita.tinybite.domain.auth.dto.response.UserDto;
import ita.tinybite.domain.auth.entity.JwtTokenProvider;
Expand All @@ -11,17 +13,27 @@
import ita.tinybite.domain.auth.kakao.KakaoApiClient.KakaoUserInfo;
import ita.tinybite.domain.auth.repository.RefreshTokenRepository;
import ita.tinybite.domain.user.constant.LoginType;
import ita.tinybite.domain.user.constant.PlatformType;
import ita.tinybite.domain.user.constant.UserStatus;
import ita.tinybite.domain.user.entity.User;
import ita.tinybite.domain.user.repository.UserRepository;
import ita.tinybite.global.exception.BusinessException;
import ita.tinybite.global.exception.errorcode.AuthErrorCode;
import ita.tinybite.global.exception.errorcode.UserErrorCode;
import ita.tinybite.global.util.NicknameGenerator;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.*;
import java.security.GeneralSecurityException;
import java.time.LocalDateTime;
import java.util.Collections;

@Slf4j
@Service
Expand All @@ -32,8 +44,18 @@ public class AuthService {
private final RefreshTokenRepository refreshTokenRepository;
private final KakaoApiClient kakaoApiClient;
private final JwtTokenProvider jwtTokenProvider;
private final JwtDecoder appleJwtDecoder;
private final NicknameGenerator nicknameGenerator;

// @Value("${apple.client-id}")
// private String appleClientId;

@Value("${google.android-id}")
private String googleAndroidId;

@Value("${google.ios-id}")
private String googleIosId;

@Transactional
public AuthResponse kakaoSignup(KakaoSignupRequest request) {
// 카카오 API로 유저 정보 조회
Expand Down Expand Up @@ -109,6 +131,129 @@ public AuthResponse kakaoLogin(KakaoLoginRequest request) {
.build();
}

@Transactional
public AuthResponse googleSignup(@Valid GoogleAndAppleSignupRequest req) {
// idToken으로 이메일 추출
String email = getEmailFromIdToken(req.idToken(), req.platform(), LoginType.GOOGLE);

// 해당 이메일의 유저 find
User user = userRepository.findByEmail(email)
.orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS));

// req필드로 유저 필드 업데이트 -> 실질적 회원가입
user.updateSignupInfo(req, email);
userRepository.save(user);

return getAuthResponse(user);
}

@Transactional
public AuthResponse googleLogin(@Valid GoogleAndAppleLoginReq req) {
// idToken으로 이메일 추출
String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.GOOGLE);
// 해당 이메일로 유저 찾은 후 응답 반환 (accessToken, refreshToken)
return getUser(email);
}

@Transactional
public AuthResponse appleSignup(@Valid GoogleAndAppleSignupRequest req) {
String email = getEmailFromIdToken(req.idToken(), req.platform(), LoginType.APPLE);

// 해당 이메일의 유저 find
User user = userRepository.findByEmail(email)
.orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS));

// req필드로 유저 필드 업데이트 -> 실질적 회원가입
user.updateSignupInfo(req, email);
userRepository.save(user);

return getAuthResponse(user);
}

@Transactional
public AuthResponse appleLogin(@Valid GoogleAndAppleLoginReq req) {
// idToken으로 이메일 추출
String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.APPLE);
// 해당 이메일로 유저 찾은 후 응답 반환 (AuthResponse)
return getUser(email);
}

private AuthResponse getAuthResponse(User user) {
// 4. JWT 토큰 생성
String accessToken = jwtTokenProvider.generateAccessToken(user);
String refreshToken = jwtTokenProvider.generateRefreshToken(user);

// 5. 기존 Refresh Token 삭제 후 새로 저장
refreshTokenRepository.deleteByUserId(user.getUserId());
saveRefreshToken(user.getUserId(), refreshToken);

log.info("로그인 성공 - User ID: {}, Email: {}", user.getUserId(), user.getEmail());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

개인정보(이메일) 로깅 - 컴플라이언스 위험

이메일 주소는 개인식별정보(PII)로, 로그에 기록하면 GDPR/CCPA 등 개인정보보호 규정 위반 가능성이 있습니다. User ID만으로 디버깅에 충분합니다.

-        log.info("로그인 성공 - User ID: {}, Email: {}", user.getUserId(), user.getEmail());
+        log.info("로그인 성공 - User ID: {}", user.getUserId());

Line 122에도 동일한 패턴이 있으니 함께 수정해 주세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
log.info("로그인 성공 - User ID: {}, Email: {}", user.getUserId(), user.getEmail());
log.info("로그인 성공 - User ID: {}", user.getUserId());
🤖 Prompt for AI Agents
In src/main/java/ita/tinybite/domain/auth/service/AuthService.java around lines
122 and 190, the log statements currently include the user's email (PII); remove
the email from the log entries and only log non-PII identifiers (e.g.,
user.getUserId()). Update both occurrences to log the user ID (or a
non-identifying hashed/masked value if desired) and ensure log text is adjusted
accordingly (no email parameter passed).


// 6. 응답 생성
return AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn(3600L)
.user(UserDto.from(user))
.build();
}

// 구글과 애플 통합
private String getEmailFromIdToken(String idToken, PlatformType platformType, LoginType loginType) {
switch(loginType) {
case GOOGLE -> {

String clientId = switch (platformType) {
case ANDROID -> googleAndroidId;
case IOS -> googleIosId;
};

try {
GoogleIdTokenVerifier googleVerifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory())
.setAudience(Collections.singletonList(clientId))
.build();

GoogleIdToken token = googleVerifier.verify(idToken);

if(token == null) {
throw BusinessException.of(AuthErrorCode.INVALID_TOKEN);
}

return token.getPayload().getEmail();

} catch (GeneralSecurityException | IOException e) {
throw BusinessException.of(AuthErrorCode.GOOGLE_LOGIN_ERROR);
}
}
case APPLE -> {
//TODO Apple 구현 예정
}
}
return null;
}
Comment on lines +202 to +234
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Apple 로그인 미구현으로 인한 NPE 위험

APPLE 케이스가 TODO로 비어있고, 메서드 끝에서 null을 반환합니다. Apple 로그인/회원가입 엔드포인트가 이미 노출되어 있어 호출 시 NPE가 발생합니다.

Apple 구현 전까지 명시적 예외를 던지도록 수정하세요:

             case APPLE -> {
-                //TODO Apple 구현 예정
+                // TODO Apple 구현 예정
+                throw BusinessException.of(AuthErrorCode.APPLE_LOGIN_ERROR);
             }
         }
-        return null;
+        throw new IllegalStateException("Unsupported login type: " + loginType);
     }

또한, GoogleIdTokenVerifier를 매 요청마다 생성하는 것은 비효율적입니다. 빈으로 등록하거나 필드로 캐싱하는 것을 권장합니다.


private AuthResponse getUser(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> {
// 이메일로 가입된 유저가 없을 시, INACTIVE로 임시 생성
// 회원가입 시 해당 임시 유저를 통해 마저 회원가입 진행
userRepository.save(User.builder()
.email(email)
.status(UserStatus.INACTIVE)
.build());

return BusinessException.of(UserErrorCode.USER_NOT_EXISTS);
});

// 3. 탈퇴한 사용자 체크
if (user.getStatus() == UserStatus.WITHDRAW) {
throw new RuntimeException("탈퇴한 사용자입니다.");
}

return getAuthResponse(user);
}
Comment on lines +236 to +255
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat src/main/java/ita/tinybite/domain/auth/service/AuthService.java | head -300 | tail -100

Repository: tinybite-2025/tinybite-server

Length of output: 3713


🏁 Script executed:

rg "UserErrorCode" -A 2 -B 2 --max-count=20

Repository: tinybite-2025/tinybite-server

Length of output: 4439


🏁 Script executed:

fd "User.java" -o java/entity -o java/domain

Repository: tinybite-2025/tinybite-server

Length of output: 214


🏁 Script executed:

rg "class User" -A 10 --type java

Repository: tinybite-2025/tinybite-server

Length of output: 5198


🏁 Script executed:

rg "enum UserErrorCode" -A 30

Repository: tinybite-2025/tinybite-server

Length of output: 1661


🏁 Script executed:

fd "User.java" -type f | head -5

Repository: tinybite-2025/tinybite-server

Length of output: 243


🏁 Script executed:

rg "class User" -A 50 --type java

Repository: tinybite-2025/tinybite-server

Length of output: 18490


🏁 Script executed:

rg "updateSignupInfo" -B 5 -A 20

Repository: tinybite-2025/tinybite-server

Length of output: 6253


🏁 Script executed:

rg "LoginType" --type java -B 2 -A 2

Repository: tinybite-2025/tinybite-server

Length of output: 7825


🏁 Script executed:

rg "USER_WITHDRAWN\|WITHDRAW" --type java -B 2 -A 2 | head -40

Repository: tinybite-2025/tinybite-server

Length of output: 55


🏁 Script executed:

cat src/main/java/ita/tinybite/global/exception/errorcode/UserErrorCode.java

Repository: tinybite-2025/tinybite-server

Length of output: 618


일관성 없는 예외 처리 - 에러 코드 누락

Line 251에서 RuntimeException을 사용하고 있으나, 다른 곳에서는 BusinessException을 사용합니다. 일관성을 위해 BusinessException으로 통일이 필요합니다.

현재 UserErrorCode에는 탈퇴 사용자 관련 에러 코드가 없으므로, 먼저 UserErrorCode enum에 새로운 에러 코드를 추가한 후 적용하세요:

// UserErrorCode.java
public enum UserErrorCode implements ErrorCode {
    USER_NOT_EXISTS(HttpStatus.NOT_FOUND, "USER_NOT_EXISTS", "존재하지 않는 유저입니다."),
+   USER_WITHDRAWN(HttpStatus.FORBIDDEN, "USER_WITHDRAWN", "탈퇴한 사용자입니다."),
    ;
// AuthService.java
if (user.getStatus() == UserStatus.WITHDRAW) {
-   throw new RuntimeException("탈퇴한 사용자입니다.");
+   throw BusinessException.of(UserErrorCode.USER_WITHDRAWN);
}

또한 orElseThrow 블록 내에서 사용자를 생성한 후 예외를 던지는 패턴의 의도를 명확히 하기 위해 주석을 추가하면 좋습니다.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/ita/tinybite/domain/auth/service/AuthService.java around lines
236 to 255, replace the inconsistent RuntimeException thrown for withdrawn users
with a BusinessException using a new UserErrorCode (e.g., USER_WITHDRAWN or
USER_DEACTIVATED): add that new enum constant to the UserErrorCode enum, then
throw BusinessException.of(UserErrorCode.USER_WITHDRAWN) instead of new
RuntimeException(...). Also add a short clarifying comment inside the
orElseThrow block explaining that a temporary INACTIVE user is created for
email-based flow before the exception is raised.


@Transactional
public AuthResponse refreshToken(RefreshTokenRequest request) {
String refreshTokenValue = request.getRefreshToken();
Expand Down Expand Up @@ -148,6 +293,7 @@ public AuthResponse refreshToken(RefreshTokenRequest request) {
@Transactional
public void logout(Long userId) {
refreshTokenRepository.deleteByUserId(userId);
userRepository.deleteById(userId);
log.info("로그아웃 - User ID: {}", userId);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package ita.tinybite.domain.user.constant;

public enum LoginType {
KAKAO, GOOGLE
KAKAO, GOOGLE, APPLE

;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package ita.tinybite.domain.user.constant;

public enum PlatformType {
ANDROID,
IOS,
}
10 changes: 10 additions & 0 deletions src/main/java/ita/tinybite/domain/user/entity/User.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ita.tinybite.domain.user.entity;

import ita.tinybite.domain.auth.dto.request.GoogleAndAppleSignupRequest;
import ita.tinybite.domain.user.constant.LoginType;
import ita.tinybite.domain.user.constant.UserStatus;
import ita.tinybite.domain.user.dto.req.UpdateUserReqDto;
Expand Down Expand Up @@ -49,4 +50,13 @@ public void update(UpdateUserReqDto req) {
public void updateLocation(String location) {
this.location = location;
}

public void updateSignupInfo(GoogleAndAppleSignupRequest req, String email) {
this.email = (email);
this.nickname = (req.nickname());
this.phone = (req.phone());
this.location = (req.location());
this.status = UserStatus.ACTIVE;
this.type = LoginType.GOOGLE;
}
}
19 changes: 19 additions & 0 deletions src/main/java/ita/tinybite/global/config/AppleConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package ita.tinybite.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Configuration
public class AppleConfig {

private static final String APPLE_JWK_URL = "https://appleid.apple.com/auth/keys";

@Bean
public JwtDecoder appleJwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri(APPLE_JWK_URL)
.build();
}
}
Loading