Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
13 changes: 8 additions & 5 deletions .claude/skills/spring-api-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,15 @@ public class MailAccountFacade {

// Service: 하위 결과 검증
public class MailAccountCommandService {
public MailAccount create(User user, MailAccountCreateCommand command) {
validateCommand(command);
public MailAccount createGoogleMailAccount(User user, GoogleMailAccountResult result) {
validateGoogleMailAccountResult(result);
MailAccountCreateCommand command = MailAccountCreateCommand.from(MailProvider.GMAIL, result);
validateCreateCommand(command);
...
}

private void validateCommand(MailAccountCreateCommand command) { ... }
private void validateGoogleMailAccountResult(GoogleMailAccountResult result) { ... }
private void validateCreateCommand(MailAccountCreateCommand command) { ... }
}
```

Expand Down Expand Up @@ -412,8 +415,8 @@ public record RegisterRequest(String email, String password, String name) {
- Gmail 계정 연결 플로우는 로그인된 사용자가 자신의 외부 메일 계정을 추가하는 시나리오로 설계한다
- OAuth 인가 시작 단계에서는 Controller가 세션에 `state`와 시작 사용자 식별값을 저장한다
- OAuth callback 단계에서는 Controller가 세션 `state`와 현재 사용자 식별값을 먼저 검증한 후 Facade를 호출한다
- Facade는 외부 OAuth 응답을 `*Result`로 정리하고, 저장 전용 입력은 `*Command`로 변환한다
- CommandService는 provider 지원 여부, 동일 사용자 중복 연결, 타 사용자 선점, 필수 토큰/이메일 값 누락을 검증한 뒤 저장한다
- Facade는 Controller에서 내려온 입력을 검증하고, 외부 OAuth 응답을 `*Result`로 정리해 CommandService로 전달한다
- CommandService는 외부 OAuth `*Result`, `*Command`, 동일 사용자 중복 연결, 타 사용자 선점, 저장 결과를 검증한 뒤 저장한다

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ public enum MailAccountErrorCode implements ErrorCode {
INVALID_OAUTH_STATE(400, "MS-MAIL-INVALID-OAUTH-STATE", "유효하지 않은 OAuth state 입니다."),
OAUTH_USER_MISMATCH(403, "MS-MAIL-OAUTH-USER-MISMATCH", "OAuth 요청 사용자 정보가 일치하지 않습니다."),
INVALID_AUTHORIZATION_CODE(400, "MS-MAIL-INVALID-AUTHORIZATION-CODE", "유효하지 않은 OAuth 인가 코드입니다."),
INVALID_MAIL_ACCOUNT_ALIAS(400, "MS-MAIL-INVALID-MAIL-ACCOUNT-ALIAS", "유효하지 않은 메일 계정 별칭입니다."),
INVALID_MAIL_ACCOUNT_ICON(400, "MS-MAIL-INVALID-MAIL-ACCOUNT-ICON", "유효하지 않은 메일 계정 아이콘입니다."),
INVALID_MAIL_ACCOUNT_COLOR(400, "MS-MAIL-INVALID-MAIL-ACCOUNT-COLOR", "메일 계정 색상은 HEX 형식으로 입력해주세요."),
UNSUPPORTED_MAIL_PROVIDER(400, "MS-MAIL-UNSUPPORTED-PROVIDER", "지원하지 않는 메일 제공자입니다."),
INVALID_OAUTH_RESULT(400, "MS-MAIL-INVALID-OAUTH-RESULT", "OAuth 응답값이 올바르지 않습니다."),
GOOGLE_TOKEN_EXCHANGE_FAILED(502, "MS-MAIL-GOOGLE-TOKEN-EXCHANGE-FAILED", "Google OAuth 토큰 교환에 실패했습니다."),
GOOGLE_USER_INFO_FETCH_FAILED(502, "MS-MAIL-GOOGLE-USER-INFO-FETCH-FAILED", "Google 사용자 정보 조회에 실패했습니다."),
GOOGLE_EMAIL_NOT_VERIFIED(400, "MS-MAIL-GOOGLE-EMAIL-NOT-VERIFIED", "Google 계정의 이메일 인증이 확인되지 않았습니다. 인증된 계정으로 다시 시도해주세요."),
GOOGLE_REFRESH_TOKEN_MISSING(400, "MS-MAIL-GOOGLE-REFRESH-TOKEN-MISSING", "Google 계정 재연동이 필요합니다. 다시 동의하고 계정을 연결해주세요."),
MAIL_ACCOUNT_ALREADY_CONNECTED(409, "MS-MAIL-ACCOUNT-ALREADY-CONNECTED", "이미 연결된 메일 계정입니다."),
MAIL_ACCOUNT_ALREADY_CONNECTED_BY_ANOTHER_USER(409, "MS-MAIL-ACCOUNT-ALREADY-CONNECTED-BY-ANOTHER-USER", "다른 사용자가 이미 연결한 메일 계정입니다."),
MAIL_ACCOUNT_NOT_FOUND(404, "MS-MAIL-ACCOUNT-NOT-FOUND", "메일 계정을 찾을 수 없습니다.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.mailsangja.core.config;

import com.mailsangja.core.config.properties.GoogleOAuthProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
public class GoogleOAuthClientConfig {

@Bean
public RestClient googleOAuthRestClient(GoogleOAuthProperties googleOAuthProperties) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout((int) googleOAuthProperties.getConnectTimeout().toMillis());
requestFactory.setReadTimeout((int) googleOAuthProperties.getReadTimeout().toMillis());

return RestClient.builder()
.requestFactory(requestFactory)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.mailsangja.core.config.properties;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.List;

@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "mailsangja.oauth.google")
public class GoogleOAuthProperties {

private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(3);
private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(5);

private String clientId;
private String clientSecret;
private String redirectUri;
private String authorizationUri;
private String tokenUri;
private String userInfoUri;
private Duration connectTimeout = DEFAULT_CONNECT_TIMEOUT;
private Duration readTimeout = DEFAULT_READ_TIMEOUT;
private List<String> scopes;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@
import com.mailsangja.core.common.exception.mail.MailAccountErrorCode;
import com.mailsangja.core.common.exception.mail.MailAccountException;
import com.mailsangja.core.controller.docs.MailAccountControllerDocs;
import com.mailsangja.core.dto.mail.MailAccountAuthorizeRequest;
import com.mailsangja.core.dto.mail.MailAccountAuthorizeResponse;
import com.mailsangja.core.dto.mail.MailAccountResponse;
import com.mailsangja.core.dto.mail.MailAccountListResponse;
import com.mailsangja.core.facade.MailAccountFacade;
import com.mailsangja.db.entity.user.User;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.net.URI;
import java.util.List;
import java.util.UUID;

@RestController
Expand All @@ -24,32 +27,48 @@ public class MailAccountController implements MailAccountControllerDocs {

private static final String GOOGLE_OAUTH_STATE = "google_oauth_state";
private static final String GOOGLE_OAUTH_USER_ID = "google_oauth_user_id";
private static final String GOOGLE_OAUTH_ALIAS = "google_oauth_alias";
private static final String GOOGLE_OAUTH_ICON = "google_oauth_icon";
private static final String GOOGLE_OAUTH_COLOR = "google_oauth_color";

private final MailAccountFacade mailAccountFacade;

@Override
@GetMapping("/api/v1/mail-accounts")
public ResponseEntity<List<MailAccountListResponse>> getMyMailAccounts(@AuthUser User user) {
return ResponseEntity.ok(mailAccountFacade.getMyMailAccounts(user));
}

@Override
@GetMapping("/api/v1/mail-accounts/google/authorize")
public ResponseEntity<MailAccountAuthorizeResponse> authorizeGoogle(
@AuthUser User user,
@ModelAttribute MailAccountAuthorizeRequest request,
HttpSession session
) {
String state = UUID.randomUUID().toString();
session.setAttribute(GOOGLE_OAUTH_STATE, state);
session.setAttribute(GOOGLE_OAUTH_USER_ID, user.getId().toString());
session.setAttribute(GOOGLE_OAUTH_ALIAS, request.alias());
session.setAttribute(GOOGLE_OAUTH_ICON, request.icon());
session.setAttribute(GOOGLE_OAUTH_COLOR, request.color());

return ResponseEntity.ok(mailAccountFacade.authorizeGoogle(state));
}

@Override
@GetMapping("/api/v1/mail-accounts/google/callback")
public ResponseEntity<MailAccountResponse> googleCallback(
public ResponseEntity<Void> googleCallback(
@AuthUser User user,
@RequestParam("code") String code,
@RequestParam("state") String state,
HttpSession session
) {
String savedState = (String) session.getAttribute(GOOGLE_OAUTH_STATE);
String savedUserId = (String) session.getAttribute(GOOGLE_OAUTH_USER_ID);
String savedAlias = (String) session.getAttribute(GOOGLE_OAUTH_ALIAS);
String savedIcon = (String) session.getAttribute(GOOGLE_OAUTH_ICON);
String savedColor = (String) session.getAttribute(GOOGLE_OAUTH_COLOR);

if (savedState == null) {
throw new MailAccountException(MailAccountErrorCode.OAUTH_SESSION_NOT_FOUND);
Expand All @@ -65,8 +84,14 @@ public ResponseEntity<MailAccountResponse> googleCallback(

session.removeAttribute(GOOGLE_OAUTH_STATE);
session.removeAttribute(GOOGLE_OAUTH_USER_ID);
session.removeAttribute(GOOGLE_OAUTH_ALIAS);
session.removeAttribute(GOOGLE_OAUTH_ICON);
session.removeAttribute(GOOGLE_OAUTH_COLOR);

mailAccountFacade.handleGoogleCallback(user, code, savedAlias, savedIcon, savedColor);

return ResponseEntity.status(HttpStatus.CREATED)
.body(mailAccountFacade.handleGoogleCallback(user, code));
return ResponseEntity.status(302)
.location(URI.create("/"))
.build();
Comment thread
jjjjjk12 marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,52 @@
package com.mailsangja.core.controller.docs;

import com.mailsangja.core.common.auth.AuthUser;
import com.mailsangja.core.dto.mail.MailAccountAuthorizeRequest;
import com.mailsangja.core.dto.mail.MailAccountAuthorizeResponse;
import com.mailsangja.core.dto.mail.MailAccountResponse;
import com.mailsangja.core.dto.mail.MailAccountListResponse;
import com.mailsangja.db.entity.user.User;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpSession;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ResponseEntity;

import java.util.List;

@Tag(name = "Mail Account", description = "메일 계정 연동 API")
public interface MailAccountControllerDocs {

@Operation(
summary = "내 메일 계정 목록 조회",
description = "로그인한 사용자의 삭제되지 않은 메일 계정 목록을 조회합니다.",
security = @SecurityRequirement(name = "cookieAuth")
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "메일 계정 목록 조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = MailAccountListResponse.class)))
),
@ApiResponse(
responseCode = "401",
description = "인증 필요",
content = @Content(schema = @Schema(hidden = true))
)
})
ResponseEntity<List<MailAccountListResponse>> getMyMailAccounts(
@Parameter(hidden = true) @AuthUser User user
);

@Operation(
summary = "Google OAuth 인가 URL 생성",
description = "로그인된 사용자의 세션에 OAuth state와 userId를 저장하고 Google OAuth 인가 URL을 반환합니다.",
description = "로그인한 사용자의 세션에 OAuth state, userId, alias, icon, color를 저장하고 Google OAuth 인가 URL을 반환합니다.",
security = @SecurityRequirement(name = "cookieAuth")
)
@ApiResponses({
Expand All @@ -37,23 +63,24 @@ public interface MailAccountControllerDocs {
})
ResponseEntity<MailAccountAuthorizeResponse> authorizeGoogle(
@Parameter(hidden = true) @AuthUser User user,
@ParameterObject MailAccountAuthorizeRequest request,
@Parameter(hidden = true) HttpSession session
);

@Operation(
summary = "Google OAuth 콜백 처리",
description = "Google에서 전달된 code와 state를 검증한 뒤 MailAccount를 생성합니다. 현재는 실제 OAuth 연동 대신 스텁 결과를 저장합니다.",
description = "Google에서 전달한 code와 state를 검증한 뒤 토큰 교환, 사용자 정보 조회, MailAccount 저장을 수행하고 루트 경로로 리다이렉트합니다.",
security = @SecurityRequirement(name = "cookieAuth")
)
@ApiResponses({
@ApiResponse(
responseCode = "201",
description = "메일 계정 연동 성공",
content = @Content(schema = @Schema(implementation = MailAccountResponse.class))
responseCode = "302",
description = "메일 계정 연동 성공 후 루트 경로로 리다이렉트",
content = @Content(schema = @Schema(hidden = true))
),
@ApiResponse(
responseCode = "400",
description = "인가 코드, state 또는 OAuth 응답값이 유효하지 않음",
description = "인가 코드, state, alias, icon, color, OAuth 응답값 또는 refresh token 이 유효하지 않음",
content = @Content(schema = @Schema(hidden = true))
),
@ApiResponse(
Expand All @@ -70,13 +97,18 @@ ResponseEntity<MailAccountAuthorizeResponse> authorizeGoogle(
responseCode = "409",
description = "이미 연결된 메일 계정",
content = @Content(schema = @Schema(hidden = true))
),
@ApiResponse(
responseCode = "502",
description = "Google OAuth 토큰 교환 또는 사용자 정보 조회 실패",
content = @Content(schema = @Schema(hidden = true))
)
})
ResponseEntity<MailAccountResponse> googleCallback(
ResponseEntity<Void> googleCallback(
@Parameter(hidden = true) @AuthUser User user,
@Parameter(description = "Google OAuth 인가 코드", required = true, example = "4/0AQSTgQ...")
String code,
@Parameter(description = "세션에 저장된 OAuth state", required = true, example = "c4c6f8c2-3b2b-4c5b-9f2c-7a1d3f9a9f11")
@Parameter(description = "세션에 저장한 OAuth state", required = true, example = "c4c6f8c2-3b2b-4c5b-9f2c-7a1d3f9a9f11")
String state,
@Parameter(hidden = true) HttpSession session
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.mailsangja.core.dto.mail;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleOAuthTokenResult(
@JsonProperty("access_token")
String accessToken,
@JsonProperty("refresh_token")
String refreshToken,
@JsonProperty("expires_in")
Long expiresIn,
@JsonProperty("scope")
String scope,
@JsonProperty("token_type")
String tokenType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.mailsangja.core.dto.mail;

import com.fasterxml.jackson.annotation.JsonProperty;

public record GoogleUserInfoResult(
@JsonProperty("email")
String email,
@JsonProperty("verified_email")
Boolean verifiedEmail
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.mailsangja.core.dto.mail;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "메일 계정 연동 시작 요청")
public record MailAccountAuthorizeRequest(
@Schema(description = "사용자가 지정한 메일 계정 별칭", example = "업무 메일")
String alias,
@Schema(description = "클라이언트가 선택한 메일 계정 아이콘", example = "mail")
String icon,
@Schema(description = "클라이언트가 선택한 메일 계정 색상 HEX 값", example = "#4F46E5")
String color
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

@Schema(description = "Google OAuth 인가 URL 응답")
public record MailAccountAuthorizeResponse(
@Schema(description = "사용자를 Google 동의 화면으로 이동시키기 위한 인가 URL", example = "https://accounts.google.com/o/oauth2/v2/auth?state=example-state")
@Schema(
description = "사용자를 Google 동의 화면으로 이동시키기 위한 인가 URL",
example = "https://accounts.google.com/o/oauth2/v2/auth?client_id=example-client-id&redirect_uri=http://localhost:8080/api/v1/mail-accounts/google/callback&response_type=code&scope=openid%20email%20https://mail.google.com/&access_type=offline&prompt=consent&state=example-state"
)
String authorizationUrl
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,27 @@
public record MailAccountCreateCommand(
MailProvider provider,
String emailAddress,
String alias,
String icon,
String color,
String accessToken,
LocalDateTime accessTokenExpiresAt,
String refreshToken,
String syncHistoryId
) {
public static MailAccountCreateCommand from(MailProvider provider, GoogleMailAccountResult result) {
public static MailAccountCreateCommand from(
MailProvider provider,
GoogleMailAccountResult result,
String alias,
String icon,
String color
) {
return new MailAccountCreateCommand(
provider,
result.emailAddress(),
alias,
icon,
color,
result.accessToken(),
result.accessTokenExpiresAt(),
result.refreshToken(),
Expand Down
Loading