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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 4 additions & 2 deletions docs/s1-api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | 일시적인 오류로 로그인하지 못했어요. 다시 시도해주세요. | 구글 장애·연결 실패·시간 초과 |

##### 비고

Expand All @@ -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장 "개발용 임시 토큰"을 봅니다.
- 약관이 바뀌어 재동의가 필요해지면, 이 응답에 재동의 필요 여부를 알려주는 값을 추가할 예정입니다(약관 변경이 생길 때 추가).
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<AuthLoginResponse>> 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 <T> ResponseEntity<ApiResponse<T>> 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));
}

}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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()
);
}

}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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> 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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Loading
Loading