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
18 changes: 18 additions & 0 deletions database/migrations/20260812_01_oauth_callback_status.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ALTER TABLE oauth_authorization_request
ADD COLUMN status VARCHAR(20) NULL AFTER used_at,
ADD COLUMN failure_stage VARCHAR(30) NULL AFTER status,
ADD COLUMN failure_type VARCHAR(30) NULL AFTER failure_stage,
ADD COLUMN processing_started_at DATETIME(6) NULL AFTER failure_type,
ADD COLUMN completed_at DATETIME(6) NULL AFTER processing_started_at;

UPDATE oauth_authorization_request
SET status = CASE WHEN used_at IS NULL THEN 'ISSUED' ELSE 'LEGACY' END,
processing_started_at = used_at,
completed_at = used_at
WHERE status IS NULL;

ALTER TABLE oauth_authorization_request
MODIFY COLUMN status VARCHAR(20) NOT NULL;

CREATE INDEX idx_oauth_authorization_request_status_processing
ON oauth_authorization_request (status, processing_started_at);
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.example.todayEng.domain.user.entity;

import com.example.todayEng.domain.user.entity.enums.ExternalServiceProvider;
import com.example.todayEng.domain.user.entity.enums.OAuthAuthorizationRequestStatus;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureStage;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureType;
import com.example.todayEng.global.common.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
Expand All @@ -23,6 +26,10 @@
@Index(
name = "idx_oauth_authorization_request_expires_at",
columnList = "expires_at"
),
@Index(
name = "idx_oauth_authorization_request_status_processing",
columnList = "status, processing_started_at"
)
}
)
Expand Down Expand Up @@ -62,6 +69,24 @@ public class OAuthAuthorizationRequest extends BaseTimeEntity {
@Column(name = "used_at")
private LocalDateTime usedAt;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private OAuthAuthorizationRequestStatus status;

@Enumerated(EnumType.STRING)
@Column(name = "failure_stage", length = 30)
private OAuthCallbackFailureStage failureStage;

@Enumerated(EnumType.STRING)
@Column(name = "failure_type", length = 30)
private OAuthCallbackFailureType failureType;

@Column(name = "processing_started_at")
private LocalDateTime processingStartedAt;

@Column(name = "completed_at")
private LocalDateTime completedAt;

private OAuthAuthorizationRequest(
User user,
ExternalServiceProvider provider,
Expand All @@ -72,6 +97,7 @@ private OAuthAuthorizationRequest(
this.provider = provider;
this.stateHash = stateHash;
this.expiresAt = expiresAt;
this.status = OAuthAuthorizationRequestStatus.ISSUED;
}

public static OAuthAuthorizationRequest create(
Expand All @@ -93,6 +119,14 @@ public boolean isExpired(LocalDateTime now) {
}

public boolean isUsed() {
return usedAt != null;
return status != OAuthAuthorizationRequestStatus.ISSUED;
}

public void succeed(LocalDateTime completedAt) {
if (status != OAuthAuthorizationRequestStatus.PROCESSING) {
throw new IllegalStateException("OAuth authorization request is not processing");
}
this.status = OAuthAuthorizationRequestStatus.SUCCEEDED;
this.completedAt = completedAt;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.todayEng.domain.user.entity.enums;

public enum OAuthAuthorizationRequestStatus {
LEGACY,
ISSUED,
PROCESSING,
SUCCEEDED,
FAILED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.todayEng.domain.user.entity.enums;

public enum OAuthCallbackFailureStage {
CALLBACK_VALIDATION,
TOKEN_EXCHANGE,
USER_INFO,
ACCOUNT_SAVE,
STALE_PROCESSING
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.todayEng.domain.user.entity.enums;

public enum OAuthCallbackFailureType {
AUTHORIZATION_DENIED,
AUTHORIZATION_CODE_MISSING,
EXTERNAL_API,
DATABASE,
CONFLICT,
INTERNAL
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import com.example.todayEng.domain.user.entity.OAuthAuthorizationRequest;
import com.example.todayEng.domain.user.entity.enums.ExternalServiceProvider;
import com.example.todayEng.domain.user.entity.enums.OAuthAuthorizationRequestStatus;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureStage;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureType;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
Expand All @@ -27,16 +30,54 @@ Optional<OAuthAuthorizationRequest> findByStateHashAndProvider(
)
@Query("""
UPDATE OAuthAuthorizationRequest request
SET request.usedAt = :usedAt
SET request.usedAt = :usedAt, request.processingStartedAt = :usedAt,
request.status = :processing
WHERE request.id = :requestId
AND request.usedAt IS NULL
AND request.status = :issued
AND request.expiresAt > :usedAt
""")
int consumeIfAvailable(
int startProcessingIfIssued(
@Param("requestId") Long requestId,
@Param("usedAt") LocalDateTime usedAt
@Param("usedAt") LocalDateTime usedAt,
@Param("issued") OAuthAuthorizationRequestStatus issued,
@Param("processing") OAuthAuthorizationRequestStatus processing
);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT request FROM OAuthAuthorizationRequest request WHERE request.id = :requestId")
Optional<OAuthAuthorizationRequest> findByIdForUpdate(
@Param("requestId") Long requestId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE OAuthAuthorizationRequest request
SET request.status = :failed, request.failureStage = :stage,
request.failureType = :failureType, request.completedAt = :completedAt
WHERE request.id = :requestId AND request.status = :processing
""")
int markFailed(@Param("requestId") Long requestId,
@Param("processing") OAuthAuthorizationRequestStatus processing,
@Param("failed") OAuthAuthorizationRequestStatus failed,
@Param("stage") OAuthCallbackFailureStage stage,
@Param("failureType") OAuthCallbackFailureType failureType,
@Param("completedAt") LocalDateTime completedAt);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE OAuthAuthorizationRequest request
SET request.status = :failed, request.failureStage = :stage,
request.failureType = :failureType, request.completedAt = :now
WHERE request.status = :processing
AND request.processingStartedAt <= :staleBefore
""")
int failStaleProcessing(
@Param("processing") OAuthAuthorizationRequestStatus processing,
@Param("failed") OAuthAuthorizationRequestStatus failed,
@Param("stage") OAuthCallbackFailureStage stage,
@Param("failureType") OAuthCallbackFailureType failureType,
@Param("staleBefore") LocalDateTime staleBefore,
@Param("now") LocalDateTime now);

@Modifying(
clearAutomatically = true,
flushAutomatically = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
import com.example.todayEng.domain.user.dto.oauth.OAuthTokenResponse;
import com.example.todayEng.domain.user.dto.response.OAuthAuthorizationResponse;
import com.example.todayEng.domain.user.entity.enums.ExternalServiceProvider;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureStage;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureType;
import com.example.todayEng.domain.user.service.OAuthAuthorizationRequestService.ProcessingClaim;
import com.example.todayEng.global.error.ErrorCode;
import com.example.todayEng.global.error.exception.BaseException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.dao.DataAccessException;

@Service
@RequiredArgsConstructor
Expand All @@ -21,8 +25,7 @@ public class ExternalAccountOAuthService {
private final OAuthProviderClientRegistry
oauthProviderClientRegistry;

private final ExternalAccountConnectionService
externalAccountConnectionService;
private final OAuthCallbackCompletionService oauthCallbackCompletionService;

public OAuthAuthorizationResponse createAuthorizationUrl(
Long userId,
Expand Down Expand Up @@ -50,35 +53,53 @@ public void connectExternalAccount(
String state,
String error
) {
Long userId =
ProcessingClaim claim =
oauthAuthorizationRequestService
.validateAndConsume(
.startProcessing(
state,
provider
);
OAuthCallbackFailureStage stage = OAuthCallbackFailureStage.CALLBACK_VALIDATION;
try {
validateOAuthCallback(code, error);
OAuthProviderClient providerClient = oauthProviderClientRegistry.getClient(provider);

stage = OAuthCallbackFailureStage.TOKEN_EXCHANGE;
OAuthTokenResponse tokenResponse = providerClient.exchangeToken(code);

stage = OAuthCallbackFailureStage.USER_INFO;
ExternalUserInfo externalUserInfo =
providerClient.fetchUserInfo(tokenResponse.accessToken());

stage = OAuthCallbackFailureStage.ACCOUNT_SAVE;
oauthCallbackCompletionService.saveAccountAndSucceed(
claim.requestId(), claim.userId(), provider,
tokenResponse, externalUserInfo);
} catch (RuntimeException exception) {
oauthAuthorizationRequestService.fail(
claim.requestId(), stage, classifyFailure(exception));
throw exception;
}
}

validateOAuthCallback(
code,
error
);

OAuthProviderClient providerClient =
oauthProviderClientRegistry.getClient(provider);

OAuthTokenResponse tokenResponse =
providerClient.exchangeToken(code);

ExternalUserInfo externalUserInfo =
providerClient.fetchUserInfo(
tokenResponse.accessToken()
);

externalAccountConnectionService.saveOrUpdate(
userId,
provider,
tokenResponse,
externalUserInfo
);
private OAuthCallbackFailureType classifyFailure(RuntimeException exception) {
if (exception instanceof BaseException baseException) {
return switch (baseException.getErrorCode()) {
case OAUTH_AUTHORIZATION_DENIED ->
OAuthCallbackFailureType.AUTHORIZATION_DENIED;
case OAUTH_AUTHORIZATION_CODE_MISSING ->
OAuthCallbackFailureType.AUTHORIZATION_CODE_MISSING;
case EXTERNAL_ACCOUNT_ALREADY_LINKED ->
OAuthCallbackFailureType.CONFLICT;
case OAUTH_TOKEN_EXCHANGE_FAILED, OAUTH_USER_INFO_FAILED, EXTERNAL_API_ERROR ->
OAuthCallbackFailureType.EXTERNAL_API;
default -> OAuthCallbackFailureType.INTERNAL;
};
}
if (exception instanceof DataAccessException) {
return OAuthCallbackFailureType.DATABASE;
}
return OAuthCallbackFailureType.INTERNAL;
}

private void validateOAuthCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.example.todayEng.domain.user.service;

import com.example.todayEng.domain.user.entity.enums.OAuthAuthorizationRequestStatus;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureStage;
import com.example.todayEng.domain.user.entity.enums.OAuthCallbackFailureType;
import com.example.todayEng.domain.user.repository.OAuthAuthorizationRequestRepository;
import java.time.Duration;
import java.time.LocalDateTime;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class OAuthAuthorizationRequestRecoveryScheduler {

private static final Duration PROCESSING_TIMEOUT = Duration.ofMinutes(15);

private final OAuthAuthorizationRequestRepository repository;

@Scheduled(fixedDelayString = "${oauth.callback.recovery-interval-millis:60000}")
@Transactional
public void failStaleProcessingRequests() {
LocalDateTime now = LocalDateTime.now();
int updated = repository.failStaleProcessing(
OAuthAuthorizationRequestStatus.PROCESSING,
OAuthAuthorizationRequestStatus.FAILED,
OAuthCallbackFailureStage.STALE_PROCESSING,
OAuthCallbackFailureType.INTERNAL,
now.minus(PROCESSING_TIMEOUT),
now
);
if (updated > 0) {
log.warn("Stale OAuth callback requests marked failed: count={}", updated);
}
}
}
Loading
Loading