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
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,16 @@ public GoogleAuthResponse googleAuth(String idToken) {
if (user == null) {
return GoogleAuthResponse.signupRequired(googleSignupStore.save(info), email, info.name());
}
rejectWithdrawn(user);

Optional<AuthIdentity> googleIdentity = findGoogleIdentity(user);
if (googleIdentity.isPresent()) {
if (!googleIdentity.get().getProviderUid().equals(info.sub())) {
throw new AuthBusinessException(AuthErrorCode.GOOGLE_AUTH_FAILED);
}
authSessionService.restoreIfPossible(user);
return GoogleAuthResponse.login(authSessionService.openSession(user, AuthProvider.GOOGLE));
}
rejectWithdrawn(user);
return GoogleAuthResponse.linkRequired(email);
}

Expand Down Expand Up @@ -349,11 +350,17 @@ private User saveUser(SignupCommand command, String email) {
}
}

/** 탈퇴한 회원이면 ACCOUNT_DELETION_IN_PROGRESS 에러로 거절한다. */
/**
* 탈퇴한 회원이면 거절한다. 유예기간(30일) 이내면 로그인을 유도하는 ACCOUNT_DORMANT로,
* 유예기간이 지났다면 ACCOUNT_DELETION_IN_PROGRESS로 구분해 응답한다.
*/
private void rejectWithdrawn(User user) {
if (user.getDeletedAt() == null) {
return;
}
if (user.isWithinWithdrawalGracePeriod(LocalDateTime.now(clock))) {
throw new AuthBusinessException(AuthErrorCode.ACCOUNT_DORMANT);
}
throw new AuthBusinessException(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,24 @@ public class AuthSessionService {
private final RefreshTokenStore refreshTokenStore;
private final Clock clock;

/** 비밀번호 검증을 마친 로컬 사용자를 잠근 뒤 탈퇴 여부를 확인하고 세션을 발급한다. */
/** 비밀번호 검증을 마친 로컬 사용자를 잠근 뒤 탈퇴 여부를 확인하고(유예기간 이내면 복구) 세션을 발급한다. */
@Transactional
public TokenResponse openLocalSession(UUID authenticatedUserId) {
User user = userRepository.findByIdForUpdate(authenticatedUserId)
.orElseThrow(() -> new AuthBusinessException(AuthErrorCode.INVALID_CREDENTIALS));
if (user.getDeletedAt() != null) {
restoreIfPossible(user);
return openSession(user, AuthProvider.LOCAL);
}

/** 탈퇴 유예기간 이내면 계정을 복구하고, 지났다면 탈퇴 처리 예외를 던진다. */
public void restoreIfPossible(User user) {
if (user.getDeletedAt() == null) {
return;
}
boolean restored = user.restoreIfWithinGracePeriod(LocalDateTime.now(clock));
if (!restored) {
throw new AuthBusinessException(AuthErrorCode.ACCOUNT_DELETION_IN_PROGRESS);
}
return openSession(user, AuthProvider.LOCAL);
}

/** 새 refresh family를 열고 토큰 쌍을 발급한다. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public enum AuthErrorCode implements ErrorCode {
ACCOUNT_REGISTERED_WITH_GOOGLE(HttpStatus.UNAUTHORIZED, "AUTH-010", "Google 계정으로 가입된 이메일입니다. Google 로그인을 이용해 주세요."),
GOOGLE_SIGNUP_SESSION_INVALID(HttpStatus.BAD_REQUEST, "AUTH-011", "가입 세션이 만료되었습니다. Google 로그인을 다시 시도해 주세요."),
LOGIN_METHOD_LOOKUP_COOLDOWN(HttpStatus.TOO_MANY_REQUESTS, "AUTH-012", "조회 요청이 많습니다. 잠시 후 다시 시도해 주세요."),
ACCOUNT_DELETION_IN_PROGRESS(HttpStatus.CONFLICT, "AUTH-013", "탈퇴 처리된 계정입니다.");
ACCOUNT_DELETION_IN_PROGRESS(HttpStatus.CONFLICT, "AUTH-013", "탈퇴 처리된 계정입니다."),
ACCOUNT_DORMANT(HttpStatus.CONFLICT, "AUTH-014", "휴면 처리된 계정입니다. 로그인해 주세요.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
102 changes: 67 additions & 35 deletions src/main/java/chaeso/zip/server/auth/presentation/AuthApiDocs.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,19 @@ public interface AuthApiDocs {
}
""";

String ACCOUNT_DORMANT_EXAMPLE = """
{
"success": false,
"data": null,
"error": {
"code": "AUTH-014",
"message": "휴면 처리된 계정입니다. 로그인해 주세요.",
"fieldErrors": []
},
"code": null
}
""";

String VERIFICATION_CODE_SEND_COOLDOWN_EXAMPLE = """
{
"success": false,
Expand All @@ -99,9 +112,11 @@ public interface AuthApiDocs {

@Operation(operationId = "sendSignupCode", summary = "회원가입 이메일 인증코드 발송",
description = """
가입할 이메일로 6자리 인증코드를 발송한다. \
로컬로 이미 가입된 이메일: 409. \
구글로만 가입된 이메일: 발송하지 않고 200 과 code: EMAIL_ALREADY_USED_WITH_GOOGLE. \
가입할 이메일로 6자리 인증코드를 발송한다.
로컬로 이미 가입된 이메일: 409.
탈퇴 후 30일 이내(휴면) 이메일: 발송하지 않고 409(AUTH-014), 로그인을 안내한다.
탈퇴 후 30일이 지난 이메일: 409(AUTH-013).
구글로만 가입된 이메일: 발송하지 않고 200 과 code: EMAIL_ALREADY_USED_WITH_GOOGLE.
그 외: 발송 후 200.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",
description = "발송 성공. 구글로만 가입된 이메일이면 발송 없이 안내 코드만 실린다",
Expand All @@ -113,10 +128,12 @@ public interface AuthApiDocs {
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE)))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409",
description = "이미 사용 중이거나 탈퇴 처리 중인 이메일",
description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), "
+ "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = {
@ExampleObject(name = "EMAIL_ALREADY_EXISTS", value = EMAIL_ALREADY_EXISTS_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)
}))
Expand Down Expand Up @@ -162,7 +179,9 @@ public interface AuthApiDocs {
""";

@Operation(operationId = "signup", summary = "회원가입 최종 제출",
description = "이메일 인증 완료 후 로컬 계정을 생성한다. 인증 미완료 시 400, 이메일 중복 시 409.")
description = """
이메일 인증 완료 후 로컬 계정을 생성한다. 인증 미완료 시 400, 이메일 중복 시 409.
탈퇴 후 30일 이내(휴면) 이메일이면 가입 대신 409(AUTH-014)로 로그인을 안내한다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "가입 성공")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패 또는 이메일 미인증",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
Expand All @@ -171,10 +190,12 @@ public interface AuthApiDocs {
@ExampleObject(name = "EMAIL_NOT_VERIFIED", value = EMAIL_NOT_VERIFIED_EXAMPLE)
}))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409",
description = "이미 사용 중이거나 탈퇴 처리 중인 이메일",
description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), "
+ "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = {
@ExampleObject(name = "EMAIL_ALREADY_EXISTS", value = EMAIL_ALREADY_EXISTS_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)
}))
Expand Down Expand Up @@ -208,9 +229,11 @@ public interface AuthApiDocs {

@Operation(operationId = "login", summary = "로컬 로그인",
description = """
이메일/비밀번호로 로그인하고 access/refresh 토큰을 발급한다. 자격증명이 올바르지 않으면 401. \
구글로만 가입되어 로컬 인증정보가 없는 이메일이면 AUTH-010 으로 별도 응답한다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공")
이메일/비밀번호로 로그인하고 access/refresh 토큰을 발급한다. 자격증명이 올바르지 않으면 401.
구글로만 가입되어 로컬 인증정보가 없는 이메일이면 AUTH-010 으로 별도 응답한다.
탈퇴 후 30일 이내(휴면) 계정이 이메일/비번이 맞으면 자동으로 복구한 뒤 로그인 처리.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",
description = "로그인 성공(탈퇴 후 30일 이내 자동 복구)")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패(C-001)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE)))
Expand All @@ -223,7 +246,7 @@ public interface AuthApiDocs {
value = ACCOUNT_REGISTERED_WITH_GOOGLE_EXAMPLE)
}))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409",
description = "탈퇴 처리된 계정(AUTH-013)",
description = "탈퇴 후 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)))
Expand Down Expand Up @@ -264,12 +287,12 @@ public interface AuthApiDocs {

@Operation(operationId = "loginMethods", summary = "로그인 수단 조회",
description = """
이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용. \
이메일로 사용 가능한 로그인 수단을 조회한다. 비밀번호 입력 전 화면 분기용.

methods \
[LOCAL]: 비밀번호 입력창. \
[LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼. \
[GOOGLE]: 구글로 가입된 계정 안내. \
methods
[LOCAL]: 비밀번호 입력창.
[LOCAL, GOOGLE]: 비밀번호 입력창과 구글 버튼.
[GOOGLE]: 구글로 가입된 계정 안내.
[]: 미가입 -> 회원가입 화면.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공",
useReturnTypeSchema = true,
Expand Down Expand Up @@ -316,10 +339,10 @@ ApiResponse<LoginMethodsResponse> loginMethods(

@Operation(operationId = "refresh", summary = "토큰 재발급",
description = """
Refresh Token 을 회전시켜 새 access/refresh 토큰 쌍을 발급한다. \
기존 Refresh Token 은 즉시 폐기된다. 이미 회전된 토큰을 다시 제출하면 재사용으로 보고 \
해당 세션(family) 전체를 폐기하므로, 동시에 여러 번 호출하지 말고 한 번만 보낸다. \
Access Token 이 만료된 상태에서 호출하므로 인증이 필요 없다. \
Refresh Token 을 회전시켜 새 access/refresh 토큰 쌍을 발급한다.
기존 Refresh Token 은 즉시 폐기된다. 이미 회전된 토큰을 다시 제출하면 재사용으로 보고
해당 세션(family) 전체를 폐기하므로, 동시에 여러 번 호출하지 말고 한 번만 보낸다.
Access Token 이 만료된 상태에서 호출하므로 인증이 필요 없다.
refreshTokenExpiresIn 은 로그인 후 90일에 가까워질수록 짧아진다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재발급 성공")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패(C-001)",
Expand Down Expand Up @@ -411,14 +434,16 @@ ApiResponse<LoginMethodsResponse> loginMethods(

@Operation(operationId = "googleAuth", summary = "구글 인증 진입",
description = """
구글 idToken 을 검증하고 계정 상태에 따라 status 로 분기한다. \
탈퇴 처리된 계정인 경우 가입 수단과 관계없이 409(AUTH-013) 예외를 반환한다. \

LOGIN: 토큰 발급. \
LINK_REQUIRED: linkRequired 와 email 을 내려주며, 사용자 확인 후 POST /auth/google/link 호출. \
구글 idToken 을 검증하고 계정 상태에 따라 status 로 분기한다.
이미 구글이 연결된 계정이 탈퇴 후 30일 이내(휴면)라면 자동으로 복구한 뒤 LOGIN 으로 응답한다.
아직 구글이 연결되지 않은 탈퇴 계정(LINK_REQUIRED 대상)이거나 유예기간이 지난 탈퇴 계정이면
409 예외(AUTH-014 또는 AUTH-013)를 반환한다.

LOGIN: 토큰 발급.
LINK_REQUIRED: linkRequired 와 email 을 내려주며, 사용자 확인 후 POST /auth/google/link 호출.
SIGNUP_REQUIRED: signupRequired 와 일회성 signupToken, 프리필 값을 내려준다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",
description = "로그인 성공 / 연결 확인 필요 / 가입 필요",
description = "로그인 성공(탈퇴 후 30일 이내 자동 복구) / 연결 확인 필요 / 가입 필요",
useReturnTypeSchema = true,
content = @Content(
examples = {
Expand All @@ -434,10 +459,14 @@ ApiResponse<LoginMethodsResponse> loginMethods(
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(name = "GOOGLE_AUTH_FAILED", value = GOOGLE_AUTH_FAILED_EXAMPLE)))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409",
description = "탈퇴 처리된 계정(AUTH-013)",
description = "구글 미연결 상태로 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), "
+ "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = @ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)))
examples = {
@ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)
}))
ApiResponse<GoogleAuthResponse> googleAuth(@Valid @RequestBody GoogleAuthRequest request);

String GOOGLE_ACCOUNT_LINKED = "GOOGLE_ACCOUNT_LINKED";
Expand All @@ -457,8 +486,8 @@ ApiResponse<LoginMethodsResponse> loginMethods(

@Operation(operationId = "linkGoogle", summary = "구글 계정 연결 확인",
description = """
linkRequired:true 응답을 받고 사용자가 '예'를 선택했을 때만 호출한다. \
진입 때 보낸 idToken 을 재전송하면 재검증 후 같은 이메일의 로컬 계정에 구글 로그인을 \
linkRequired:true 응답을 받고 사용자가 '예'를 선택했을 때만 호출한다.
진입 때 보낸 idToken 을 재전송하면 재검증 후 같은 이메일의 로컬 계정에 구글 로그인을
연결하고 토큰을 발급한다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",
description = "연결 완료(code: GOOGLE_ACCOUNT_LINKED) 및 토큰 발급",
Expand Down Expand Up @@ -501,9 +530,10 @@ ApiResponse<LoginMethodsResponse> loginMethods(

@Operation(operationId = "signupGoogle", summary = "구글 최종 회원가입",
description = """
POST /auth/google 이 내려준 signupToken 과 추가 프로필(닉네임/회사명/직무/약관 동의)로 \
신규 회원가입을 완료하고 토큰을 발급한다. \
가입에 성공하면 signupToken 은 즉시 폐기되어 재사용 불가능.""")
POST /auth/google 이 내려준 signupToken 과 추가 프로필(닉네임/회사명/직무/약관 동의)로
신규 회원가입을 완료하고 토큰을 발급한다.
가입에 성공하면 signupToken 은 즉시 폐기되어 재사용 불가능.
탈퇴 후 30일 이내(휴면) 이메일이면 가입 대신 409(AUTH-014)로 로그인을 안내한다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "가입 성공, 토큰 발급",
useReturnTypeSchema = true,
content = @Content(
Expand All @@ -517,10 +547,12 @@ ApiResponse<LoginMethodsResponse> loginMethods(
value = GOOGLE_SIGNUP_SESSION_INVALID_EXAMPLE)
}))
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409",
description = "이미 사용 중이거나 탈퇴 처리 중인 이메일",
description = "이미 사용 중인 이메일(AUTH-002), 탈퇴 후 유예기간 이내라 로그인이 필요한 휴면 계정(AUTH-014), "
+ "또는 유예기간이 지나 탈퇴 처리 중인 계정(AUTH-013)",
content = @Content(schema = @Schema(implementation = ApiResponse.class),
examples = {
@ExampleObject(name = "EMAIL_ALREADY_EXISTS", value = EMAIL_ALREADY_EXISTS_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DORMANT", value = ACCOUNT_DORMANT_EXAMPLE),
@ExampleObject(name = "ACCOUNT_DELETION_IN_PROGRESS",
value = ACCOUNT_DELETION_IN_PROGRESS_EXAMPLE)
}))
Expand All @@ -533,7 +565,7 @@ ApiResponse<LoginMethodsResponse> loginMethods(
@SecurityRequirement(name = "bearerAuth")
@Operation(operationId = "logout", summary = "로그아웃",
description = """
제출한 Refresh Token 의 세션(family)을 폐기한다. 인증된 사용자만 호출할 수 있고, \
제출한 Refresh Token 의 세션(family)을 폐기한다. 인증된 사용자만 호출할 수 있고,
토큰 소유자가 인증 사용자와 다르면 401. 이미 폐기된 세션이어도 200 을 반환한다.""")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그아웃 성공")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패(C-001)",
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/chaeso/zip/server/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

/** 탈퇴 후 재로그인 시 계정을 복구해주는 유예기간(일). */
public static final long WITHDRAWAL_GRACE_PERIOD_DAYS = 30;

@Column(nullable = false)
private String email;

Expand Down Expand Up @@ -108,6 +111,24 @@ public void withdraw(LocalDateTime now) {
sessionVersion++;
}

/**
* 탈퇴 후 유예기간 이내 재로그인이면 계정을 복구한다.
*
* @return 복구되었으면 true, 유예기간이 지났거나 탈퇴 상태가 아니면 false
*/
public boolean restoreIfWithinGracePeriod(LocalDateTime now) {
if (!isWithinWithdrawalGracePeriod(now)) {
return false;
}
deletedAt = null;
return true;
}

/** 탈퇴 상태이면서 유예기간(복구 가능 기간) 이내인지 확인한다. 상태를 변경하지 않는다. */
public boolean isWithinWithdrawalGracePeriod(LocalDateTime now) {
return deletedAt != null && !deletedAt.plusDays(WITHDRAWAL_GRACE_PERIOD_DAYS).isBefore(now);
}

/** 사용자의 직군/회사명을 수정한다 */
public void updateProfile(String companyName, Occupation occupation) {
this.companyName = companyName;
Expand Down
Loading
Loading