diff --git a/CLAUDE.md b/CLAUDE.md index 3e8f3f6..4844a95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,8 @@ domain/{도메인}/ | 보유 템플릿 개수 | `long countActive(Long userId)` | template → user | | AI 오늘 사용량 | `AiToneUsageResult getTodayUsage(Long userId)` (`AiToneUsageQueryService`) | ai → user | | 가입 시 회원 생성 | `Long signUp(UserSignUpCommand)` | user → auth | +| 로그인 시각 기록 | `void recordLogin(Long userId)` (`UserCommandService`, 호출자 트랜잭션 필수) | user → auth | +| 닉네임 조회 | `String getNickname(Long userId)` (`UserQueryService`) | user → auth | | 회원 행 잠금 | `void lockUser(Long userId)` (`UserCommandService`, 호출자 트랜잭션 필수) | user → template | - **다른 도메인 서비스를 조합하는 조회는 별도 서비스로 분리한다.** 예: 사용량 API는 `UserUsageQueryService` diff --git a/docs/s1-api-spec.md b/docs/s1-api-spec.md index ecdba10..5cbabb3 100644 --- a/docs/s1-api-spec.md +++ b/docs/s1-api-spec.md @@ -377,7 +377,7 @@ POST /api/v1/auth/login | --- | --- | --- | --- | | 400 | AUTH400_1 | 지원하지 않는 로그인 방식이에요. | provider 값 오류 (S1은 GOOGLE만) | | 401 | AUTH401_3 | 구글 로그인 정보를 확인할 수 없어요. 다시 시도해주세요. | 인가 코드가 잘못됨·만료·이미 사용됨, 허용 목록에 없는 redirectUri, ID 토큰 검증 실패, 이메일 미인증 계정 | -| 502 | AUTH502_1 | 일시적인 오류로 로그인하지 못했어요. 다시 시도해 주세요. | 구글 장애·연결 실패·시간 초과 | +| 502 | AUTH502_1 | 일시적인 오류로 로그인하지 못했어요. 다시 시도해주세요. | 구글 장애·연결 실패·시간 초과 | ##### 비고 @@ -386,7 +386,9 @@ POST /api/v1/auth/login - code·codeVerifier·redirectUri가 비어 있으면 `COMMON400_1`입니다. - 인가 코드는 한 번만 쓸 수 있습니다. 시간 초과로 끝났어도 구글은 이미 교환했을 수 있으니, `AUTH502_1`을 받으면 같은 요청을 다시 보내지 말고 구글 로그인부터 다시 시작해 주세요. - 처음 온 사용자는 이 단계에서 회원을 만들지 않습니다. **약관 동의 전에는 개인정보를 저장하지 않기 위해서**입니다. 구글 계정 정보를 담은 가입용 토큰(30분)을 주고, 동의 API에서 회원을 만듭니다. -- 웹: 재발급 토큰은 응답 본문이 아니라 `Set-Cookie`(HttpOnly, Secure, 14일)로 줍니다. +- 웹: 재발급 토큰은 응답 본문이 아니라 `Set-Cookie`(이름 `refreshToken`, HttpOnly, Secure, 14일, `Path=/api/v1/auth`)로 줍니다. `SameSite` 기본값은 `Lax`이고, 화면과 API가 다른 사이트인 환경은 서버 설정으로 `None`을 씁니다. 가입(2번)도 같습니다. +- provider가 없거나 `GOOGLE`이 아니면 `AUTH400_1`입니다. +- 연결된 회원이 탈퇴했으면 `USER404_1`입니다 (공통 규칙 1장과 같은 코드). - 로그인 화면은 랜딩(S3)에 생기며, 그 전까지 프론트는 대시보드에 임시 로그인 버튼을 둡니다. - 개발 환경에서만 쓰는 임시 토큰 발급 경로를 따로 열어, 구글 로그인 완성 전에도 다른 API를 개발할 수 있게 합니다(운영 배포에서는 비활성). 경로와 요청·응답은 공통 규칙 1장 "개발용 임시 토큰"을 봅니다. - 약관이 바뀌어 재동의가 필요해지면, 이 응답에 재동의 필요 여부를 알려주는 값을 추가할 예정입니다(약관 변경이 생길 때 추가). diff --git a/src/main/java/com/sniptext/domain/auth/controller/AuthController.java b/src/main/java/com/sniptext/domain/auth/controller/AuthController.java new file mode 100644 index 0000000..aacc5f8 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/controller/AuthController.java @@ -0,0 +1,62 @@ +package com.sniptext.domain.auth.controller; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.sniptext.domain.auth.controller.dto.AuthLoginRequest; +import com.sniptext.domain.auth.controller.dto.AuthLoginResponse; +import com.sniptext.domain.auth.service.AuthCommandService; +import com.sniptext.domain.auth.service.RefreshTokenIssuer; +import com.sniptext.domain.auth.service.dto.result.AuthLoginResult; +import com.sniptext.global.response.ApiResponse; +import com.sniptext.global.response.ResultCode; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +/** + * 로그인 (로그인 불필요, 캐시 금지). 재발급 토큰은 본문이 아니라 HttpOnly·Secure 쿠키로만 내보낸다. + * 쿠키 경로를 인증 API로 좁혀 다른 API 요청에는 재발급 토큰이 실려 가지 않게 한다. + */ +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private static final String REFRESH_TOKEN_COOKIE = "refreshToken"; + private static final String COOKIE_PATH = "/api/v1/auth"; + + private final AuthCommandService authCommandService; + private final AuthCookieProperties cookieProperties; + + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody AuthLoginRequest request) { + AuthLoginResult result = authCommandService.login(request.toCommand()); + AuthLoginResponse response = AuthLoginResponse.from(result); + + if (!result.completedSignUp()) { + return ResponseEntity.ok(new ApiResponse<>(true, AuthSuccessCode.LOGGED_IN.getCode(), + AuthSuccessCode.SIGN_UP_REQUIRED_MESSAGE, response)); + } + return withRefreshToken(AuthSuccessCode.LOGGED_IN, response, result.refreshToken()); + } + + private ResponseEntity> withRefreshToken(ResultCode resultCode, T response, String refreshToken) { + ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE, refreshToken) + .httpOnly(true) + .secure(true) + .sameSite(cookieProperties.sameSite()) + .path(COOKIE_PATH) + .maxAge(RefreshTokenIssuer.TTL) + .build(); + return ResponseEntity.status(resultCode.getHttpStatus()) + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(ApiResponse.of(resultCode, response)); + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/controller/AuthCookieProperties.java b/src/main/java/com/sniptext/domain/auth/controller/AuthCookieProperties.java new file mode 100644 index 0000000..2f59e8e --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/controller/AuthCookieProperties.java @@ -0,0 +1,18 @@ +package com.sniptext.domain.auth.controller; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.Pattern; + +/** + * 재발급 토큰 쿠키 설정. 화면과 API가 같은 사이트면 기본값 Lax로 쿠키가 오간다. + * 서로 다른 사이트에서 호출하는 환경(예: 로컬 화면 → 개발 서버)은 None이어야 쿠키가 오간다. + */ +@Validated +@ConfigurationProperties("sniptext.auth.cookie") +public record AuthCookieProperties( + @DefaultValue("Lax") @Pattern(regexp = "Strict|Lax|None") String sameSite +) { +} diff --git a/src/main/java/com/sniptext/domain/auth/controller/AuthSuccessCode.java b/src/main/java/com/sniptext/domain/auth/controller/AuthSuccessCode.java new file mode 100644 index 0000000..31d1b99 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/controller/AuthSuccessCode.java @@ -0,0 +1,26 @@ +package com.sniptext.domain.auth.controller; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthSuccessCode implements ResultCode { + + LOGGED_IN(HttpStatus.OK, "AUTH200_2", "로그인에 성공했습니다."); + + /** + * 처음 온 사용자의 로그인 응답 문구 (명세 1번). 코드는 LOGGED_IN과 같은 AUTH200_2다. + * 결과 코드 하나에 상수 하나만 둘 수 있어(ResultCodeRegistry가 중복이면 기동을 멈춘다) 문구만 따로 둔다. + */ + public static final String SIGN_UP_REQUIRED_MESSAGE = "약관 동의가 필요합니다."; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/auth/controller/dto/AuthLoginRequest.java b/src/main/java/com/sniptext/domain/auth/controller/dto/AuthLoginRequest.java new file mode 100644 index 0000000..ef5555c --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/controller/dto/AuthLoginRequest.java @@ -0,0 +1,24 @@ +package com.sniptext.domain.auth.controller.dto; + +import com.sniptext.domain.auth.service.dto.command.AuthLoginCommand; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +/** + * 구글 로그인 (명세 1번). 빈 값이 구글까지 가면 구글 오류(AUTH502_1)로 둔갑하므로 여기서 COMMON400_1로 막는다. + * provider를 열거형으로 받으면 잘못된 값이 AUTH400_1이 아니라 파싱 실패(COMMON400_1)가 되어 문자열로 받는다. + */ +public record AuthLoginRequest( + @NotBlank String code, + @NotBlank String codeVerifier, + @NotBlank String redirectUri, + @NotNull(message = "AUTH400_1") @Pattern(regexp = "GOOGLE", message = "AUTH400_1") String provider +) { + + public AuthLoginCommand toCommand() { + return new AuthLoginCommand(code, codeVerifier, redirectUri); + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/controller/dto/AuthLoginResponse.java b/src/main/java/com/sniptext/domain/auth/controller/dto/AuthLoginResponse.java new file mode 100644 index 0000000..f1578dc --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/controller/dto/AuthLoginResponse.java @@ -0,0 +1,31 @@ +package com.sniptext.domain.auth.controller.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.sniptext.domain.auth.service.dto.result.AuthLoginResult; + +/** 구글 로그인 결과 (명세 1번). 재발급 토큰은 본문에 넣지 않고 쿠키로 내보낸다. */ +public record AuthLoginResponse( + @JsonProperty("isCompletedSignUp") boolean isCompletedSignUp, + String accessToken, + Long userId, + String nickname, + String signupToken, + String email, + String name, + String profileImageUrl +) { + + public static AuthLoginResponse from(AuthLoginResult result) { + return new AuthLoginResponse( + result.completedSignUp(), + result.accessToken(), + result.userId(), + result.nickname(), + result.signupToken(), + result.email(), + result.name(), + result.profileImageUrl() + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/sniptext/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 0000000..3e48aea --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,25 @@ +package com.sniptext.domain.auth.exception; + +import org.springframework.http.HttpStatus; + +import com.sniptext.global.response.ResultCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ResultCode { + + /** provider 값 오류 (S1은 GOOGLE만). 요청 객체의 검증 어노테이션이 코드로 쓴다 */ + UNSUPPORTED_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH400_1", "지원하지 않는 로그인 방식이에요."), + /** 인가 코드가 잘못됨·만료·이미 사용됨, 허용 목록에 없는 redirectUri, ID 토큰 검증 실패, 이메일 미인증 계정 */ + GOOGLE_LOGIN_REJECTED(HttpStatus.UNAUTHORIZED, "AUTH401_3", "구글 로그인 정보를 확인할 수 없어요. 다시 시도해주세요."), + /** 구글 장애·연결 실패·시간 초과 */ + GOOGLE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "AUTH502_1", "일시적인 오류로 로그인하지 못했어요. 다시 시도해주세요."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/com/sniptext/domain/auth/service/AuthCommandService.java b/src/main/java/com/sniptext/domain/auth/service/AuthCommandService.java new file mode 100644 index 0000000..525c95d --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/service/AuthCommandService.java @@ -0,0 +1,76 @@ +package com.sniptext.domain.auth.service; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import com.sniptext.domain.auth.entity.SocialAccount; +import com.sniptext.domain.auth.entity.SocialProvider; +import com.sniptext.domain.auth.exception.AuthErrorCode; +import com.sniptext.domain.auth.repository.SocialAccountRepository; +import com.sniptext.domain.auth.service.dto.command.AuthLoginCommand; +import com.sniptext.domain.auth.service.dto.result.AuthLoginResult; +import com.sniptext.domain.user.service.UserCommandService; +import com.sniptext.domain.user.service.UserQueryService; +import com.sniptext.global.client.google.GoogleAccount; +import com.sniptext.global.client.google.GoogleAuthCodeClient; +import com.sniptext.global.client.google.GoogleLoginException; +import com.sniptext.global.exception.BusinessException; +import com.sniptext.global.security.JwtTokenProvider; +import com.sniptext.global.security.SignupTokenClaims; + +import lombok.RequiredArgsConstructor; + +/** + * 구글 로그인. 클래스에 @Transactional을 붙이지 않는다. + * 구글 호출(최대 수 초) 동안 DB 연결을 잡고 있지 않도록, DB 작업만 TransactionTemplate으로 묶는다. + */ +@Service +@RequiredArgsConstructor +public class AuthCommandService { + + private final GoogleAuthCodeClient googleAuthCodeClient; + private final SocialAccountRepository socialAccountRepository; + private final UserCommandService userCommandService; + private final UserQueryService userQueryService; + private final RefreshTokenIssuer refreshTokenIssuer; + private final JwtTokenProvider jwtTokenProvider; + private final TransactionTemplate transactionTemplate; + + /** + * 구글 인가 코드로 로그인한다. 사용자 실패는 AUTH401_3, 구글 장애는 AUTH502_1. + * 처음 온 사용자는 회원을 만들지 않고 가입용 토큰만 준다 (약관 동의 전에는 개인정보를 저장하지 않는다). + * 연결된 회원이 탈퇴했으면 USER404_1이다. + */ + public AuthLoginResult login(AuthLoginCommand command) { + GoogleAccount account = exchangeCode(command); + + Optional socialAccount = socialAccountRepository.findByProviderAndProviderUserId( + SocialProvider.GOOGLE, account.subject()); + if (socialAccount.isEmpty()) { + String signupToken = jwtTokenProvider.issueSignupToken(new SignupTokenClaims( + account.subject(), account.email(), account.name(), account.pictureUrl())); + return AuthLoginResult.signUpRequired(signupToken, account.email(), account.name(), account.pictureUrl()); + } + + Long userId = socialAccount.get().getUserId(); + return transactionTemplate.execute(status -> { + userCommandService.recordLogin(userId); + String refreshToken = refreshTokenIssuer.issueForNewLogin(userId); + return AuthLoginResult.loggedIn(jwtTokenProvider.issueAccessToken(userId), refreshToken, userId, + userQueryService.getNickname(userId)); + }); + } + + // 구글 호출 실패 원인에는 응답 내용이 들어 있을 수 있어 원인으로 붙이지 않는다 + private GoogleAccount exchangeCode(AuthLoginCommand command) { + try { + return googleAuthCodeClient.exchange(command.code(), command.codeVerifier(), command.redirectUri()) + .orElseThrow(() -> new BusinessException(AuthErrorCode.GOOGLE_LOGIN_REJECTED)); + } catch (GoogleLoginException e) { + throw new BusinessException(AuthErrorCode.GOOGLE_UNAVAILABLE); + } + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/service/RefreshTokenIssuer.java b/src/main/java/com/sniptext/domain/auth/service/RefreshTokenIssuer.java new file mode 100644 index 0000000..091b8d0 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/service/RefreshTokenIssuer.java @@ -0,0 +1,64 @@ +package com.sniptext.domain.auth.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.auth.entity.ClientType; +import com.sniptext.domain.auth.entity.RefreshToken; +import com.sniptext.domain.auth.repository.RefreshTokenRepository; + +import lombok.RequiredArgsConstructor; + +/** + * 재발급 토큰 발급. 원문은 쿠키로만 내보내고 DB에는 SHA-256 해시만 저장한다. + * 토큰 저장이 회원 저장과 함께 커밋되도록 호출자 트랜잭션 안에서만 동작하게 강제한다. + */ +@Component +@RequiredArgsConstructor +public class RefreshTokenIssuer { + + public static final Duration TTL = Duration.ofDays(14); + + private static final int TOKEN_BYTES = 32; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final RefreshTokenRepository refreshTokenRepository; + private final Clock clock; + + /** 새 로그인의 웹 재발급 토큰을 저장하고 원문을 돌려준다. 새 로그인이므로 계보(family_id)도 새로 만든다. */ + @Transactional(propagation = Propagation.MANDATORY) + public String issueForNewLogin(Long userId) { + String rawToken = generate(); + refreshTokenRepository.save(RefreshToken.create(userId, ClientType.WEB, hash(rawToken), + UUID.randomUUID().toString(), LocalDateTime.now(clock).plus(TTL))); + return rawToken; + } + + private static String generate() { + byte[] bytes = new byte[TOKEN_BYTES]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String hash(String rawToken) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256을 쓸 수 없는 환경이다", e); + } + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/service/dto/command/AuthLoginCommand.java b/src/main/java/com/sniptext/domain/auth/service/dto/command/AuthLoginCommand.java new file mode 100644 index 0000000..3faa647 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/service/dto/command/AuthLoginCommand.java @@ -0,0 +1,12 @@ +package com.sniptext.domain.auth.service.dto.command; + +/** 구글 로그인 입력. 값은 요청 단계에서 빈 값 검증을 마쳤다. */ +public record AuthLoginCommand(String code, String codeVerifier, String redirectUri) { + + /** 인가 코드와 코드 검증값은 로그에 남기지 않는다. */ + @Override + public String toString() { + return "AuthLoginCommand[redirectUri=%s]".formatted(redirectUri); + } + +} diff --git a/src/main/java/com/sniptext/domain/auth/service/dto/result/AuthLoginResult.java b/src/main/java/com/sniptext/domain/auth/service/dto/result/AuthLoginResult.java new file mode 100644 index 0000000..b466825 --- /dev/null +++ b/src/main/java/com/sniptext/domain/auth/service/dto/result/AuthLoginResult.java @@ -0,0 +1,33 @@ +package com.sniptext.domain.auth.service.dto.result; + +/** + * 구글 로그인 결과. 가입된 회원이면 토큰과 회원 정보가, 처음 온 사용자면 가입용 토큰과 구글 계정 정보가 채워진다. + * refreshToken은 응답 본문이 아니라 쿠키로 내보낸다. + */ +public record AuthLoginResult( + boolean completedSignUp, + String accessToken, + String refreshToken, + Long userId, + String nickname, + String signupToken, + String email, + String name, + String profileImageUrl +) { + + public static AuthLoginResult loggedIn(String accessToken, String refreshToken, Long userId, String nickname) { + return new AuthLoginResult(true, accessToken, refreshToken, userId, nickname, null, null, null, null); + } + + public static AuthLoginResult signUpRequired(String signupToken, String email, String name, String profileImageUrl) { + return new AuthLoginResult(false, null, null, null, null, signupToken, email, name, profileImageUrl); + } + + /** 토큰과 이메일은 로그에 남기지 않는다. */ + @Override + public String toString() { + return "AuthLoginResult[completedSignUp=%s, userId=%s]".formatted(completedSignUp, userId); + } + +} diff --git a/src/main/java/com/sniptext/domain/user/service/UserCommandService.java b/src/main/java/com/sniptext/domain/user/service/UserCommandService.java index 9ca05bb..62cd401 100644 --- a/src/main/java/com/sniptext/domain/user/service/UserCommandService.java +++ b/src/main/java/com/sniptext/domain/user/service/UserCommandService.java @@ -55,6 +55,17 @@ public Long signUp(UserSignUpCommand command) { return user.getId(); } + /** + * 로그인 시각을 남긴다. 호출자(auth의 로그인 처리) 트랜잭션에 합류해 재발급 토큰 저장과 함께 커밋된다. + * 없거나 탈퇴한 회원이면 USER404_1. + */ + @Transactional(propagation = Propagation.MANDATORY) + public void recordLogin(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + user.recordLogin(LocalDateTime.now(clock)); + } + /** * 회원 행을 비관적 쓰기 잠금으로 잡는다. 템플릿 생성·복구·갤러리 저장의 한도 검사 전에 부른다. * 자기 트랜잭션을 열면 메서드가 끝날 때 잠금이 풀려 이어지는 개수 검사·저장이 잠금 없이 실행되므로, diff --git a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java index 4c8855b..6cd5046 100644 --- a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java +++ b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java @@ -3,7 +3,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sniptext.domain.user.entity.User; +import com.sniptext.domain.user.exception.UserErrorCode; import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.global.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -15,4 +18,11 @@ public class UserQueryService { private final UserRepository userRepository; + /** 로그인·가입 응답에 담을 닉네임. 없거나 탈퇴한 회원이면 USER404_1. */ + public String getNickname(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .map(User::getNickname) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + } + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d29a373..602c30b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -41,6 +41,10 @@ sniptext: provider: ${AI_PROVIDER} api-key: ${AI_API_KEY:} model: ${AI_MODEL:} + # 재발급 토큰 쿠키. 화면과 API가 다른 사이트인 환경만 None으로 바꾼다. + auth: + cookie: + same-site: ${AUTH_COOKIE_SAME_SITE:Lax} # 구글 로그인 (인가 코드 교환 + ID 토큰 검증). 아래 세 값은 기본값이 없어 빠뜨리면 기동에 실패한다. # redirect-uris는 로그인 뒤 돌아올 화면 주소 허용 목록이다 (쉼표로 구분). 요청의 redirectUri가 여기 없으면 거절한다. google: diff --git a/src/test/java/com/sniptext/domain/auth/controller/AuthControllerTest.java b/src/test/java/com/sniptext/domain/auth/controller/AuthControllerTest.java new file mode 100644 index 0000000..922c490 --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/controller/AuthControllerTest.java @@ -0,0 +1,298 @@ +package com.sniptext.domain.auth.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.IllegalTransactionStateException; + +import com.sniptext.domain.auth.entity.ClientType; +import com.sniptext.domain.auth.entity.RefreshToken; +import com.sniptext.domain.auth.repository.RefreshTokenRepository; +import com.sniptext.domain.auth.repository.SocialAccountRepository; +import com.sniptext.domain.auth.service.RefreshTokenIssuer; +import com.sniptext.domain.user.entity.User; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.domain.user.service.UserCommandService; +import com.sniptext.fixture.SocialAccountFixture; +import com.sniptext.fixture.UserFixture; +import com.sniptext.global.client.google.GoogleAccount; +import com.sniptext.global.client.google.GoogleAuthCodeClient; +import com.sniptext.global.client.google.GoogleLoginException; +import com.sniptext.global.security.AccessTokenResult; +import com.sniptext.global.security.JwtTokenProvider; +import com.sniptext.global.security.SignupTokenClaims; +import com.sniptext.support.IntegrationTestSupport; +import com.sniptext.support.MutableClock; +import com.sniptext.support.MutableClockConfiguration; + +import tools.jackson.databind.json.JsonMapper; + +/** + * 명세 1번. 구글 호출만 가짜로 두고 필터·트랜잭션·MySQL까지 실제로 거친다. + * 클래스에 @Transactional을 붙이지 않는다. 붙이면 호출자 트랜잭션 강제(MANDATORY)가 드러나지 않는다. 대신 회원을 직접 지운다 + * (소셜 계정·토큰은 외래키 CASCADE로 함께 지워진다). + */ +@Import(MutableClockConfiguration.class) +class AuthControllerTest extends IntegrationTestSupport { + + private static final String LOGIN_PATH = "/api/v1/auth/login"; + private static final LocalDateTime FIXED = LocalDateTime.of(2026, 11, 5, 10, 0); + private static final Pattern REFRESH_COOKIE = Pattern.compile("^refreshToken=([A-Za-z0-9_-]+);"); + private static final String CODE = "4/0AQSTgQ"; + private static final String VERIFIER = "pkce-verifier"; + private static final String REDIRECT_URI = "https://sniptext.io/auth/google/callback"; + private static final String SUB = "109876543210987654321"; + private static final String EMAIL = "new-user@gmail.com"; + private static final String NAME = "홍길동"; + private static final String PICTURE = "https://lh3.googleusercontent.com/a/sample"; + private static final GoogleAccount ACCOUNT = new GoogleAccount(SUB, EMAIL, NAME, PICTURE); + + @MockitoBean + private GoogleAuthCodeClient googleAuthCodeClient; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private UserRepository userRepository; + + @Autowired + private SocialAccountRepository socialAccountRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private UserCommandService userCommandService; + + @Autowired + private RefreshTokenIssuer refreshTokenIssuer; + + @Autowired + private MutableClock clock; + + @Autowired + private JsonMapper jsonMapper; + + @BeforeEach + void setUp() { + clock.setTo(FIXED); + } + + @AfterEach + void tearDown() { + userRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("처음 온 사용자는 200과 가입용 토큰을 받고, 회원·쿠키는 만들어지지 않는다") + void loginFirstTime() throws Exception { + given(googleAuthCodeClient.exchange(CODE, VERIFIER, REDIRECT_URI)).willReturn(Optional.of(ACCOUNT)); + + MvcResult result = login().andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("AUTH200_2")) + .andExpect(jsonPath("$.message").value("약관 동의가 필요합니다.")) + .andExpect(jsonPath("$.result.isCompletedSignUp").value(false)) + .andExpect(jsonPath("$.result.accessToken").value(nullValue())) + .andExpect(jsonPath("$.result.userId").value(nullValue())) + .andExpect(jsonPath("$.result.nickname").value(nullValue())) + .andExpect(jsonPath("$.result.email").value(EMAIL)) + .andExpect(jsonPath("$.result.name").value(NAME)) + .andExpect(jsonPath("$.result.profileImageUrl").value(PICTURE)) + .andReturn(); + + assertThat(jwtTokenProvider.parseSignupToken(resultField(result, "signupToken"))) + .contains(new SignupTokenClaims(SUB, EMAIL, NAME, PICTURE)); + assertThat(userRepository.count()).isZero(); + assertThat(refreshTokenRepository.count()).isZero(); + } + + @Test + @DisplayName("가입된 회원은 접근 토큰과 재발급 쿠키를 받고, 로그인 시각이 기록되며, 본문에 재발급 토큰이 없다") + void loginSignedUp() throws Exception { + Long userId = signedUpUser(); + given(googleAuthCodeClient.exchange(CODE, VERIFIER, REDIRECT_URI)).willReturn(Optional.of(ACCOUNT)); + + MvcResult result = login().andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("AUTH200_2")) + .andExpect(jsonPath("$.message").value("로그인에 성공했습니다.")) + .andExpect(jsonPath("$.result.isCompletedSignUp").value(true)) + .andExpect(jsonPath("$.result.completedSignUp").doesNotExist()) + .andExpect(jsonPath("$.result.userId").value(userId)) + .andExpect(jsonPath("$.result.nickname").value(UserFixture.NICKNAME)) + .andExpect(jsonPath("$.result.signupToken").value(nullValue())) + .andExpect(jsonPath("$.result.email").value(nullValue())) + .andExpect(jsonPath("$.result.name").value(nullValue())) + .andExpect(jsonPath("$.result.profileImageUrl").value(nullValue())) + .andExpect(jsonPath("$.result.refreshToken").doesNotExist()) + .andReturn(); + + assertThat(jwtTokenProvider.parseAccessToken(resultField(result, "accessToken"))) + .isEqualTo(new AccessTokenResult.Authenticated(userId)); + assertRefreshToken(result, userId); + assertThat(userRepository.findById(userId).map(User::getLastLoginAt)).contains(FIXED); + } + + @Test + @DisplayName("재발급 쿠키는 HttpOnly·Secure·14일·인증 경로·SameSite=Lax이다") + void refreshCookieAttributes() throws Exception { + signedUpUser(); + given(googleAuthCodeClient.exchange(CODE, VERIFIER, REDIRECT_URI)).willReturn(Optional.of(ACCOUNT)); + + String cookie = login().andReturn().getResponse().getHeader(HttpHeaders.SET_COOKIE); + + assertThat(cookie).contains("HttpOnly", "Secure", "SameSite=Lax", "Path=/api/v1/auth", + "Max-Age=" + Duration.ofDays(14).toSeconds()); + } + + @Test + @DisplayName("구글이 거절하면 AUTH401_3이고 쿠키가 없다") + void loginRejected() throws Exception { + given(googleAuthCodeClient.exchange(CODE, VERIFIER, REDIRECT_URI)).willReturn(Optional.empty()); + + login().andExpect(status().isUnauthorized()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)) + .andExpect(jsonPath("$.code").value("AUTH401_3")) + .andExpect(jsonPath("$.message").value("구글 로그인 정보를 확인할 수 없어요. 다시 시도해주세요.")) + .andExpect(jsonPath("$.result").value(nullValue())); + } + + @Test + @DisplayName("구글 장애면 AUTH502_1이고 캐시하지 않는다") + void loginGoogleUnavailable() throws Exception { + given(googleAuthCodeClient.exchange(CODE, VERIFIER, REDIRECT_URI)) + .willThrow(new GoogleLoginException("구글 토큰 교환 통신 실패")); + + login().andExpect(status().isBadGateway()) + .andExpect(header().string(HttpHeaders.CACHE_CONTROL, "no-store")) + .andExpect(jsonPath("$.code").value("AUTH502_1")) + .andExpect(jsonPath("$.message").value("일시적인 오류로 로그인하지 못했어요. 다시 시도해주세요.")); + } + + @Test + @DisplayName("연결된 회원이 탈퇴했으면 USER404_1이고 토큰을 만들지 않는다") + void loginDeletedUser() throws Exception { + Long userId = userRepository.save(UserFixture.deletedUser(EMAIL, FIXED)).getId(); + socialAccountRepository.save(SocialAccountFixture.google(userId, SUB)); + given(googleAuthCodeClient.exchange(CODE, VERIFIER, REDIRECT_URI)).willReturn(Optional.of(ACCOUNT)); + + login().andExpect(status().isNotFound()) + .andExpect(header().doesNotExist(HttpHeaders.SET_COOKIE)) + .andExpect(jsonPath("$.code").value("USER404_1")); + + assertThat(refreshTokenRepository.count()).isZero(); + } + + @ParameterizedTest + @ValueSource(strings = {"code", "codeVerifier", "redirectUri"}) + @DisplayName("code·codeVerifier·redirectUri가 비면 COMMON400_1이고 구글을 부르지 않는다") + void loginBlankValues(String blankField) throws Exception { + String body = """ + {"code": "%s", "codeVerifier": "%s", "redirectUri": "%s", "provider": "GOOGLE"} + """.formatted(blankField.equals("code") ? " " : CODE, blankField.equals("codeVerifier") ? " " : VERIFIER, + blankField.equals("redirectUri") ? " " : REDIRECT_URI); + + postLogin(body).andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400_1")); + + verify(googleAuthCodeClient, never()).exchange(anyString(), anyString(), anyString()); + } + + @ParameterizedTest + @ValueSource(strings = {"\"provider\": \"NAVER\"", "\"provider\": \"google\"", "\"provider\": null"}) + @DisplayName("provider가 GOOGLE이 아니거나 없으면 AUTH400_1이고 구글을 부르지 않는다") + void loginUnsupportedProvider(String provider) throws Exception { + postLogin(""" + {"code": "%s", "codeVerifier": "%s", "redirectUri": "%s", %s} + """.formatted(CODE, VERIFIER, REDIRECT_URI, provider)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("AUTH400_1")); + + verify(googleAuthCodeClient, never()).exchange(anyString(), anyString(), anyString()); + } + + @Test + @DisplayName("로그인 시각 기록과 재발급 토큰 저장은 트랜잭션 밖에서 부르면 IllegalTransactionStateException이다") + void requiresTransaction() { + Long userId = signedUpUser(); + + assertThatThrownBy(() -> userCommandService.recordLogin(userId)) + .isInstanceOf(IllegalTransactionStateException.class); + assertThatThrownBy(() -> refreshTokenIssuer.issueForNewLogin(userId)) + .isInstanceOf(IllegalTransactionStateException.class); + assertThat(refreshTokenRepository.count()).isZero(); + } + + private Long signedUpUser() { + Long userId = userRepository.save(UserFixture.user(EMAIL)).getId(); + socialAccountRepository.save(SocialAccountFixture.google(userId, SUB)); + return userId; + } + + private ResultActions login() throws Exception { + return postLogin(""" + {"code": "%s", "codeVerifier": "%s", "redirectUri": "%s", "provider": "GOOGLE"} + """.formatted(CODE, VERIFIER, REDIRECT_URI)); + } + + private ResultActions postLogin(String body) throws Exception { + return mockMvc.perform(post(LOGIN_PATH).contentType(MediaType.APPLICATION_JSON).content(body)); + } + + private String resultField(MvcResult result, String field) throws Exception { + return jsonMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .get("result").get(field).asString(); + } + + /** 쿠키의 원문은 DB에 그대로 있지 않고, 그 SHA-256 해시가 14일 만료의 웹 토큰으로 저장돼 있어야 한다. */ + private void assertRefreshToken(MvcResult result, Long userId) throws Exception { + Matcher matcher = REFRESH_COOKIE.matcher(result.getResponse().getHeader(HttpHeaders.SET_COOKIE)); + assertThat(matcher.find()).isTrue(); + String rawToken = matcher.group(1); + String hash = HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8))); + + RefreshToken saved = refreshTokenRepository.findByTokenHash(hash).orElseThrow(); + assertThat(saved.getTokenHash()).isNotEqualTo(rawToken); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getClientType()).isEqualTo(ClientType.WEB); + assertThat(saved.getFamilyId()).hasSize(RefreshToken.FAMILY_ID_LENGTH); + assertThat(saved.getExpiresAt()).isEqualTo(FIXED.plusDays(14)); + assertThat(saved.isRevoked()).isFalse(); + } + +} diff --git a/src/test/java/com/sniptext/domain/auth/controller/AuthCookiePropertiesTest.java b/src/test/java/com/sniptext/domain/auth/controller/AuthCookiePropertiesTest.java new file mode 100644 index 0000000..4736689 --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/controller/AuthCookiePropertiesTest.java @@ -0,0 +1,41 @@ +package com.sniptext.domain.auth.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.context.ConfigurationPropertiesAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.validation.autoconfigure.ValidationAutoConfiguration; +import org.springframework.context.annotation.Configuration; + +class AuthCookiePropertiesTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(ConfigurationPropertiesAutoConfiguration.class, + ValidationAutoConfiguration.class)) + .withUserConfiguration(AuthCookiePropertiesConfiguration.class); + + @Test + @DisplayName("SameSite 기본값은 Lax이고 None으로 바꿀 수 있다") + void sameSite() { + runner.run(context -> assertThat(context.getBean(AuthCookieProperties.class).sameSite()).isEqualTo("Lax")); + runner.withPropertyValues("sniptext.auth.cookie.same-site=None") + .run(context -> assertThat(context.getBean(AuthCookieProperties.class).sameSite()).isEqualTo("None")); + } + + @Test + @DisplayName("SameSite가 Strict·Lax·None이 아니면 기동에 실패한다") + void invalidSameSite() { + runner.withPropertyValues("sniptext.auth.cookie.same-site=lax") + .run(context -> assertThat(context).hasFailed()); + } + + @Configuration + @EnableConfigurationProperties(AuthCookieProperties.class) + static class AuthCookiePropertiesConfiguration { + } + +} diff --git a/src/test/java/com/sniptext/domain/auth/service/AuthDtoToStringTest.java b/src/test/java/com/sniptext/domain/auth/service/AuthDtoToStringTest.java new file mode 100644 index 0000000..d007a7e --- /dev/null +++ b/src/test/java/com/sniptext/domain/auth/service/AuthDtoToStringTest.java @@ -0,0 +1,30 @@ +package com.sniptext.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.sniptext.domain.auth.service.dto.command.AuthLoginCommand; +import com.sniptext.domain.auth.service.dto.result.AuthLoginResult; + +class AuthDtoToStringTest { + + @Test + @DisplayName("입력 객체의 toString에 인가 코드와 코드 검증값이 나오지 않는다") + void loginCommand() { + String text = new AuthLoginCommand("secret-code", "secret-verifier", "https://sniptext.io/cb").toString(); + + assertThat(text).doesNotContain("secret-code", "secret-verifier"); + } + + @Test + @DisplayName("결과 객체의 toString에 토큰과 이메일이 나오지 않는다") + void loginResult() { + assertThat(AuthLoginResult.loggedIn("secret-access", "secret-refresh", 1L, "스닙이").toString()) + .doesNotContain("secret-access", "secret-refresh"); + assertThat(AuthLoginResult.signUpRequired("secret-signup", "user@gmail.com", "홍길동", null).toString()) + .doesNotContain("secret-signup", "user@gmail.com"); + } + +}