diff --git a/eeos/docs/auth_README.md b/eeos/docs/auth_README.md index 3c5791e18..524d1a180 100644 --- a/eeos/docs/auth_README.md +++ b/eeos/docs/auth_README.md @@ -122,3 +122,85 @@ Slack OAuth2 로그인 후 추가 정보(성함, 기수, 활동상태)를 제출 - `auth/application/exception/AlreadyLinkedAccountException` : 에러 코드 4201 - `auth/application/exception/SlackMemberNotFoundException` : 에러 코드 4200 - `auth/presentation/dto/EeosSignUpRequest` : 회원가입 요청 DTO + +--- + +## OAuth 클라이언트 등록 (ADMIN 전용) + +### POST `/api/v2/auth/clients` + +OAuth2 클라이언트를 등록한다. `WEB` 타입은 BCrypt 해시된 `clientSecret`이 발급되고, `APP` 타입은 발급되지 않는다. + +- 인증: JWT 필수 (ADMIN 역할) +- 구현: `ClientController` → `ClientService` + +#### 요청 + +```json +{ + "clientName": "eeos-web-app", + "clientType": "WEB", + "redirectUris": [ + "https://eeos.econovation.kr/callback", + "http://localhost:3000/callback" + ] +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|------|------|------|------| +| `clientName` | string | O | 클라이언트 이름 (공백 불가) | +| `clientType` | string | O | `WEB` 또는 `APP` (대소문자 무관) | +| `redirectUris` | string[] | O | 허용 리다이렉트 URI 목록. 1개 이상, 최대 10개, URI당 최대 512자 | + +#### clientType 동작 차이 + +| clientType | 기밀 클라이언트 | clientSecret 발급 | +|------------|--------------|-----------------| +| `WEB` | O | O (BCrypt 해시 저장, 원본 1회 반환) | +| `APP` | X | X (null 반환) | + +#### 응답 + +**HTTP 201 Created** + +`clientSecret`는 `WEB` 타입일 때만 포함된다. 이후 재조회 불가 — 최초 응답에서 반드시 저장할 것. + +```json +{ + "success": true, + "code": "CREATE", + "data": { + "clientId": "a3f7c2d1-85b4-4e9a-bf32-1c0e7d9fa821", + "clientSecret": "xKz3Qp9mRvLs7wNt2YhJ4dUiOeAn0BfCgXvPqWmE5c" + } +} +``` + +#### 오류 + +| HTTP | 코드 | 메시지 | 발생 조건 | +|------|------|--------|-----------| +| 400 | 4015 | 등록되지 않은 redirect URI입니다. | `redirectUris`가 비어있거나 10개 초과, 또는 URI가 512자 초과 | +| 403 | — | 관리자 권한 필요 | ADMIN 역할 없음 | + +#### 보안 설정 근거 + +`SecurityFilterChainConfig`의 `authenticated` 체인에서 다음과 같이 설정되어 있다. + +```java +// 매처: POST /api/v2/auth/clients를 authenticated 체인에 포함 +.requestMatchers(HttpMethod.POST, "/api/v2/auth/clients") + +// 권한: ADMIN 역할만 허용 +requests.requestMatchers(HttpMethod.POST, "/api/v2/auth/clients").hasAnyRole(ADMIN); +``` + +#### 연관 컴포넌트 + +- `auth/presentation/controller/ClientController` : 진입점 +- `auth/presentation/docs/ClientApi` : Swagger 인터페이스 (`@Tag`, `@Operation`, `@ApiResponses`) +- `auth/application/service/ClientService` : 등록 로직, 시크릿 생성 (`SecureRandom`, 32바이트, Base64url) +- `auth/application/domain/ClientType` : `WEB(confidential=true)`, `APP(confidential=false)` +- `auth/application/exception/InvalidRedirectUriException` : 에러 코드 4015 +- `auth/persistence/client/ClientEntity` : JPA 엔티티 (클라이언트 + 리다이렉트 URI) diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/AuthorizationCodeData.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/AuthorizationCodeData.java deleted file mode 100644 index 1d6dbf30a..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/AuthorizationCodeData.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.blackcompany.eeos.auth.application.domain; - -import java.io.Serializable; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class AuthorizationCodeData implements Serializable { - private Long memberId; - private String clientId; - private String codeChallenge; - private String codeChallengeMethod; - private String redirectUri; -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/ClientType.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/ClientType.java deleted file mode 100644 index b4ca8390a..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/ClientType.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.blackcompany.eeos.auth.application.domain; - -public enum ClientType { - WEB(true), - APP(false); - - private final boolean confidential; - - ClientType(boolean confidential) { - this.confidential = confidential; - } - - public boolean isConfidential() { - return confidential; - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/PkceValidator.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/PkceValidator.java deleted file mode 100644 index 1897e10b7..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/domain/PkceValidator.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.blackcompany.eeos.auth.application.domain; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; - -public class PkceValidator { - - private PkceValidator() {} - - public static boolean validate(String codeVerifier, String codeChallenge, String method) { - if (!"S256".equals(method)) { - throw new IllegalArgumentException("Unsupported code_challenge_method: " + method); - } - - try { - byte[] digest = - MessageDigest.getInstance("SHA-256") - .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); - String computed = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - return MessageDigest.isEqual( - computed.getBytes(StandardCharsets.UTF_8), - codeChallenge.getBytes(StandardCharsets.UTF_8)); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 not available", e); - } - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/converter/TokenResponseConverter.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/converter/TokenResponseConverter.java index 7d4344928..5450b239f 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/converter/TokenResponseConverter.java +++ b/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/converter/TokenResponseConverter.java @@ -5,6 +5,7 @@ @Component public class TokenResponseConverter { + public TokenResponse from(String accessToken, Long accessExpiredTime) { return TokenResponse.builder() .accessToken(accessToken) diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/request/ClientRegistrationRequest.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/request/ClientRegistrationRequest.java deleted file mode 100644 index 34056498c..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/request/ClientRegistrationRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.blackcompany.eeos.auth.application.dto.request; - -import java.util.Set; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class ClientRegistrationRequest { - private String clientName; - private String clientType; - private Set redirectUris; -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/response/ClientRegistrationResponse.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/response/ClientRegistrationResponse.java deleted file mode 100644 index f798f3b5e..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/dto/response/ClientRegistrationResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.blackcompany.eeos.auth.application.dto.response; - -import com.fasterxml.jackson.annotation.JsonInclude; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -@JsonInclude(JsonInclude.Include.NON_NULL) -public class ClientRegistrationResponse { - private String clientId; - private String clientSecret; -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/ClientService.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/ClientService.java deleted file mode 100644 index 0790e9adf..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/ClientService.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.blackcompany.eeos.auth.application.service; - -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.exception.InvalidClientException; -import com.blackcompany.eeos.auth.application.exception.InvalidRedirectUriException; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.auth.persistence.client.ClientRepository; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Set; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.bcrypt.BCrypt; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ClientService { - - private static final int MAX_REDIRECT_URIS = 10; - private static final int MAX_REDIRECT_URI_LENGTH = 512; - private static final int SECRET_BYTE_LENGTH = 32; - - private final ClientRepository clientRepository; - - @Transactional - public ClientRegistrationResult register( - String clientName, ClientType clientType, Set redirectUris) { - validateRedirectUris(redirectUris); - - String clientId = UUID.randomUUID().toString(); - String rawSecret = null; - String hashedSecret = null; - - if (clientType.isConfidential()) { - rawSecret = generateSecret(); - hashedSecret = BCrypt.hashpw(rawSecret, BCrypt.gensalt()); - } - - ClientEntity entity = - ClientEntity.builder() - .clientId(clientId) - .clientSecret(hashedSecret) - .clientName(clientName) - .clientType(clientType) - .build(); - - redirectUris.forEach(entity::addRedirectUri); - clientRepository.save(entity); - - return new ClientRegistrationResult(clientId, rawSecret); - } - - public ClientEntity findAndValidateRedirectUri(String clientId, String redirectUri) { - ClientEntity client = - clientRepository - .findByClientIdWithRedirectUris(clientId) - .orElseThrow(InvalidClientException::new); - - if (!client.hasRedirectUri(redirectUri)) { - throw new InvalidRedirectUriException(); - } - - return client; - } - - private void validateRedirectUris(Set redirectUris) { - if (redirectUris == null || redirectUris.isEmpty()) { - throw new InvalidRedirectUriException(); - } - if (redirectUris.size() > MAX_REDIRECT_URIS) { - throw new InvalidRedirectUriException(); - } - for (String uri : redirectUris) { - if (uri.length() > MAX_REDIRECT_URI_LENGTH) { - throw new InvalidRedirectUriException(); - } - } - } - - private String generateSecret() { - byte[] bytes = new byte[SECRET_BYTE_LENGTH]; - new SecureRandom().nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } - - public record ClientRegistrationResult(String clientId, String clientSecret) {} -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/OAuth2LoginService.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/OAuth2LoginService.java deleted file mode 100644 index 3211f20a9..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/OAuth2LoginService.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.blackcompany.eeos.auth.application.service; - -import com.blackcompany.eeos.auth.application.domain.AuthorizationCodeData; -import com.blackcompany.eeos.auth.application.domain.TokenModel; -import com.blackcompany.eeos.auth.application.support.AuthenticationTokenGenerator; -import com.blackcompany.eeos.auth.application.support.LoginRateLimiter; -import com.blackcompany.eeos.auth.persistence.AuthorizationCodeRepository; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.member.application.model.MemberModel; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class OAuth2LoginService { - - private final AuthService authService; - private final ClientService clientService; - private final AuthenticationTokenGenerator tokenGenerator; - private final AuthorizationCodeRepository codeRepository; - private final LoginRateLimiter rateLimiter; - - public TokenModel loginForWeb( - String clientId, String redirectUri, String email, String password, String ip) { - rateLimiter.checkRateLimit(ip, email); - ClientEntity client = clientService.findAndValidateRedirectUri(clientId, redirectUri); - - MemberModel member = authenticateWithRateLimit(email, password, ip); - rateLimiter.resetAccountCounter(email); - - return tokenGenerator.execute( - member.getMemberId(), client.getClientType().name(), client.getClientId()); - } - - public String loginForApp( - String clientId, - String redirectUri, - String email, - String password, - String ip, - String codeChallenge, - String codeChallengeMethod) { - rateLimiter.checkRateLimit(ip, email); - ClientEntity client = clientService.findAndValidateRedirectUri(clientId, redirectUri); - - MemberModel member = authenticateWithRateLimit(email, password, ip); - rateLimiter.resetAccountCounter(email); - - AuthorizationCodeData codeData = - AuthorizationCodeData.builder() - .memberId(member.getMemberId()) - .clientId(client.getClientId()) - .codeChallenge(codeChallenge) - .codeChallengeMethod(codeChallengeMethod) - .redirectUri(redirectUri) - .build(); - - return codeRepository.save(codeData); - } - - private MemberModel authenticateWithRateLimit(String email, String password, String ip) { - try { - return authService.authenticate(email, password); - } catch (Exception e) { - rateLimiter.recordFailure(ip, email); - throw e; - } - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/TokenExchangeService.java b/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/TokenExchangeService.java deleted file mode 100644 index 49135467d..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/application/service/TokenExchangeService.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.blackcompany.eeos.auth.application.service; - -import com.blackcompany.eeos.auth.application.domain.AuthorizationCodeData; -import com.blackcompany.eeos.auth.application.domain.PkceValidator; -import com.blackcompany.eeos.auth.application.domain.TokenModel; -import com.blackcompany.eeos.auth.application.exception.InvalidClientException; -import com.blackcompany.eeos.auth.application.exception.InvalidGrantException; -import com.blackcompany.eeos.auth.application.support.AuthenticationTokenGenerator; -import com.blackcompany.eeos.auth.persistence.AuthorizationCodeRepository; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.auth.persistence.client.ClientRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TokenExchangeService { - - private final AuthorizationCodeRepository codeRepository; - private final ClientRepository clientRepository; - private final AuthenticationTokenGenerator tokenGenerator; - - public TokenModel exchange( - String code, String codeVerifier, String redirectUri, String clientId) { - AuthorizationCodeData codeData = - codeRepository.findAndDelete(code).orElseThrow(InvalidGrantException::new); - - if (!codeData.getClientId().equals(clientId)) { - throw new InvalidClientException(); - } - - if (!codeData.getRedirectUri().equals(redirectUri)) { - throw new InvalidGrantException(); - } - - if (!PkceValidator.validate( - codeVerifier, codeData.getCodeChallenge(), codeData.getCodeChallengeMethod())) { - throw new InvalidGrantException(); - } - - ClientEntity client = - clientRepository - .findByClientIdWithRedirectUris(clientId) - .orElseThrow(InvalidClientException::new); - - return tokenGenerator.execute( - codeData.getMemberId(), client.getClientType().name(), client.getClientId()); - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/AuthorizationCodeRepository.java b/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/AuthorizationCodeRepository.java deleted file mode 100644 index 222e50e7b..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/AuthorizationCodeRepository.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.blackcompany.eeos.auth.persistence; - -import com.blackcompany.eeos.auth.application.domain.AuthorizationCodeData; -import com.fasterxml.jackson.databind.ObjectMapper; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Optional; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class AuthorizationCodeRepository { - - private static final String KEY_PREFIX = "auth_code:"; - private static final long TTL_SECONDS = 60; - private static final int CODE_BYTE_LENGTH = 16; - - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - - public String save(AuthorizationCodeData data) { - String code = generateCode(); - redisTemplate.opsForValue().set(KEY_PREFIX + code, data, TTL_SECONDS, TimeUnit.SECONDS); - return code; - } - - public Optional findAndDelete(String code) { - String key = KEY_PREFIX + code; - Object value = redisTemplate.opsForValue().get(key); - if (value == null) { - return Optional.empty(); - } - redisTemplate.delete(key); - return Optional.of(objectMapper.convertValue(value, AuthorizationCodeData.class)); - } - - private String generateCode() { - byte[] bytes = new byte[CODE_BYTE_LENGTH]; - new SecureRandom().nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientEntity.java b/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientEntity.java deleted file mode 100644 index 4973723f5..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientEntity.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.blackcompany.eeos.auth.persistence.client; - -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.common.persistence.BaseEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import java.util.ArrayList; -import java.util.List; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.experimental.SuperBuilder; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@SuperBuilder(toBuilder = true) -@Entity -@Table(name = ClientEntity.ENTITY_PREFIX) -public class ClientEntity extends BaseEntity { - public static final String ENTITY_PREFIX = "oauth_client"; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = ENTITY_PREFIX + "_id", nullable = false) - private Long id; - - @Column(name = ENTITY_PREFIX + "_client_id", nullable = false, unique = true, length = 36) - private String clientId; - - @Column(name = ENTITY_PREFIX + "_client_secret") - private String clientSecret; - - @Column(name = ENTITY_PREFIX + "_client_name", nullable = false, length = 100) - private String clientName; - - @Enumerated(EnumType.STRING) - @Column(name = ENTITY_PREFIX + "_client_type", nullable = false, length = 10) - private ClientType clientType; - - @OneToMany(mappedBy = "client", cascade = CascadeType.ALL, orphanRemoval = true) - @Builder.Default - private List redirectUris = new ArrayList<>(); - - public void addRedirectUri(String uri) { - redirectUris.add(ClientRedirectUriEntity.builder().client(this).redirectUri(uri).build()); - } - - public boolean hasRedirectUri(String uri) { - return redirectUris.stream().anyMatch(r -> r.getRedirectUri().equals(uri)); - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientRedirectUriEntity.java b/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientRedirectUriEntity.java deleted file mode 100644 index 9e47391d6..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientRedirectUriEntity.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.blackcompany.eeos.auth.persistence.client; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -@Entity -@Table(name = "oauth_client_redirect_uri") -public class ClientRedirectUriEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "redirect_uri_id", nullable = false) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "oauth_client_id", nullable = false) - private ClientEntity client; - - @Column(name = "redirect_uri", nullable = false, length = 512) - private String redirectUri; -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientRepository.java b/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientRepository.java deleted file mode 100644 index 157ea1c2f..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/persistence/client/ClientRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.blackcompany.eeos.auth.persistence.client; - -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -public interface ClientRepository extends JpaRepository { - - @Query("SELECT c FROM ClientEntity c LEFT JOIN FETCH c.redirectUris WHERE c.clientId = :clientId") - Optional findByClientIdWithRedirectUris(String clientId); -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/controller/ClientController.java b/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/controller/ClientController.java deleted file mode 100644 index f24b2e6c9..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/controller/ClientController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.blackcompany.eeos.auth.presentation.controller; - -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.dto.request.ClientRegistrationRequest; -import com.blackcompany.eeos.auth.application.dto.response.ClientRegistrationResponse; -import com.blackcompany.eeos.auth.application.service.ClientService; -import com.blackcompany.eeos.auth.presentation.docs.ClientApi; -import com.blackcompany.eeos.auth.presentation.support.Member; -import com.blackcompany.eeos.common.presentation.response.ApiResponse; -import com.blackcompany.eeos.common.presentation.response.ApiResponseBody.SuccessBody; -import com.blackcompany.eeos.common.presentation.response.ApiResponseGenerator; -import com.blackcompany.eeos.common.presentation.response.MessageCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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; - -@RestController -@RequestMapping("/api/v2/auth/clients") -@RequiredArgsConstructor -public class ClientController implements ClientApi { - - private final ClientService clientService; - - @Override - @PostMapping - public ApiResponse> register( - @RequestBody ClientRegistrationRequest request, @Member Long memberId) { - ClientType clientType = ClientType.valueOf(request.getClientType().toUpperCase()); - var result = - clientService.register(request.getClientName(), clientType, request.getRedirectUris()); - - ClientRegistrationResponse response = - new ClientRegistrationResponse(result.clientId(), result.clientSecret()); - - return ApiResponseGenerator.success(response, HttpStatus.CREATED, MessageCode.CREATE); - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/controller/OAuth2Controller.java b/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/controller/OAuth2Controller.java deleted file mode 100644 index 2a0bdc54d..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/controller/OAuth2Controller.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.blackcompany.eeos.auth.presentation.controller; - -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.domain.TokenModel; -import com.blackcompany.eeos.auth.application.exception.InvalidClientException; -import com.blackcompany.eeos.auth.application.exception.InvalidRedirectUriException; -import com.blackcompany.eeos.auth.application.service.ClientService; -import com.blackcompany.eeos.auth.application.service.OAuth2LoginService; -import com.blackcompany.eeos.auth.application.service.TokenExchangeService; -import com.blackcompany.eeos.auth.presentation.docs.OAuth2Api; -import com.blackcompany.eeos.auth.presentation.support.AuthCookieManager; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.HashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.util.UriComponentsBuilder; - -@RestController -@RequestMapping("/api/v2/auth") -@RequiredArgsConstructor -public class OAuth2Controller implements OAuth2Api { - - private final ClientService clientService; - private final OAuth2LoginService oAuth2LoginService; - private final TokenExchangeService tokenExchangeService; - private final AuthCookieManager cookieManager; - - @Value("${auth.login-page-url:http://localhost:3000/login}") - private String loginPageUrl; - - @Override - @GetMapping("/authorize") - public ResponseEntity authorize( - @RequestParam("client_id") String clientId, - @RequestParam("redirect_uri") String redirectUri, - @RequestParam("response_type") String responseType, - @RequestParam("state") String state, - @RequestParam(value = "code_challenge", required = false) String codeChallenge, - @RequestParam(value = "code_challenge_method", required = false) String codeChallengeMethod) { - - if (!"code".equals(responseType)) { - return ResponseEntity.badRequest().build(); - } - - var client = clientService.findAndValidateRedirectUri(clientId, redirectUri); - - if (client.getClientType() == ClientType.APP && codeChallenge == null) { - return ResponseEntity.badRequest().build(); - } - - String location = - UriComponentsBuilder.fromUriString(loginPageUrl) - .queryParam("client_id", clientId) - .queryParam("redirect_uri", redirectUri) - .queryParam("state", state) - .queryParamIfPresent("code_challenge", java.util.Optional.ofNullable(codeChallenge)) - .queryParamIfPresent( - "code_challenge_method", java.util.Optional.ofNullable(codeChallengeMethod)) - .build() - .toUriString(); - - return ResponseEntity.status(HttpStatus.FOUND).header(HttpHeaders.LOCATION, location).build(); - } - - @Override - @PostMapping("/login") - public ResponseEntity login( - @RequestParam("client_id") String clientId, - @RequestParam("redirect_uri") String redirectUri, - @RequestParam("state") String state, - @RequestParam("email") String email, - @RequestParam("password") String password, - @RequestParam(value = "code_challenge", required = false) String codeChallenge, - @RequestParam(value = "code_challenge_method", required = false) String codeChallengeMethod, - HttpServletRequest request, - HttpServletResponse response) { - - var client = validateClientOrBadRequest(clientId, redirectUri); - if (client == null) { - return ResponseEntity.badRequest().build(); - } - - String ip = request.getRemoteAddr(); - - try { - if (client.getClientType() == ClientType.WEB) { - return handleWebLogin(clientId, redirectUri, state, email, password, ip, response); - } else { - return handleAppLogin( - clientId, redirectUri, state, email, password, ip, codeChallenge, codeChallengeMethod); - } - } catch (Exception e) { - return redirectToLoginPageWithError( - clientId, redirectUri, state, codeChallenge, codeChallengeMethod); - } - } - - @Override - @PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) - public ResponseEntity> token( - @RequestParam("grant_type") String grantType, - @RequestParam("code") String code, - @RequestParam("code_verifier") String codeVerifier, - @RequestParam("redirect_uri") String redirectUri, - @RequestParam("client_id") String clientId) { - - if (!"authorization_code".equals(grantType)) { - return ResponseEntity.badRequest().build(); - } - - TokenModel tokenModel = - tokenExchangeService.exchange(code, codeVerifier, redirectUri, clientId); - - Map body = new HashMap<>(); - body.put("access_token", tokenModel.getAccessToken()); - body.put("refresh_token", tokenModel.getRefreshToken()); - body.put("token_type", "Bearer"); - body.put("expires_in", (tokenModel.getAccessExpiredTime() - System.currentTimeMillis()) / 1000); - - return ResponseEntity.ok(body); - } - - private com.blackcompany.eeos.auth.persistence.client.ClientEntity validateClientOrBadRequest( - String clientId, String redirectUri) { - try { - return clientService.findAndValidateRedirectUri(clientId, redirectUri); - } catch (InvalidClientException | InvalidRedirectUriException e) { - return null; - } - } - - private ResponseEntity handleWebLogin( - String clientId, - String redirectUri, - String state, - String email, - String password, - String ip, - HttpServletResponse response) { - TokenModel tokenModel = - oAuth2LoginService.loginForWeb(clientId, redirectUri, email, password, ip); - - ResponseCookie atCookie = cookieManager.setAccessTokenCookie(tokenModel.getAccessToken()); - ResponseCookie rtCookie = cookieManager.setRefreshTokenCookie(tokenModel.getRefreshToken()); - response.addHeader(HttpHeaders.SET_COOKIE, atCookie.toString()); - response.addHeader(HttpHeaders.SET_COOKIE, rtCookie.toString()); - - String location = - UriComponentsBuilder.fromUriString(redirectUri) - .queryParam("state", state) - .build() - .toUriString(); - - return ResponseEntity.status(HttpStatus.SEE_OTHER) - .header(HttpHeaders.LOCATION, location) - .build(); - } - - private ResponseEntity handleAppLogin( - String clientId, - String redirectUri, - String state, - String email, - String password, - String ip, - String codeChallenge, - String codeChallengeMethod) { - String code = - oAuth2LoginService.loginForApp( - clientId, redirectUri, email, password, ip, codeChallenge, codeChallengeMethod); - - String location = - UriComponentsBuilder.fromUriString(redirectUri) - .queryParam("code", code) - .queryParam("state", state) - .build() - .toUriString(); - - return ResponseEntity.status(HttpStatus.SEE_OTHER) - .header(HttpHeaders.LOCATION, location) - .build(); - } - - private ResponseEntity redirectToLoginPageWithError( - String clientId, - String redirectUri, - String state, - String codeChallenge, - String codeChallengeMethod) { - String location = - UriComponentsBuilder.fromUriString(loginPageUrl) - .queryParam("client_id", clientId) - .queryParam("redirect_uri", redirectUri) - .queryParam("state", state) - .queryParamIfPresent("code_challenge", java.util.Optional.ofNullable(codeChallenge)) - .queryParamIfPresent( - "code_challenge_method", java.util.Optional.ofNullable(codeChallengeMethod)) - .queryParam("error", "invalid_credentials") - .build() - .toUriString(); - - return ResponseEntity.status(HttpStatus.SEE_OTHER) - .header(HttpHeaders.LOCATION, location) - .build(); - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/docs/ClientApi.java b/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/docs/ClientApi.java deleted file mode 100644 index 7a9de487f..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/docs/ClientApi.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.blackcompany.eeos.auth.presentation.docs; - -import com.blackcompany.eeos.auth.application.dto.request.ClientRegistrationRequest; -import com.blackcompany.eeos.auth.application.dto.response.ClientRegistrationResponse; -import com.blackcompany.eeos.auth.presentation.support.Member; -import com.blackcompany.eeos.common.presentation.response.ApiResponse; -import com.blackcompany.eeos.common.presentation.response.ApiResponseBody.SuccessBody; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -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 org.springframework.web.bind.annotation.RequestBody; - -@Tag(name = "클라이언트 관리", description = "OAuth2 클라이언트 등록 API") -public interface ClientApi { - - @Operation( - summary = "클라이언트 등록", - description = "OAuth2 클라이언트를 등록한다. WEB은 client_secret이 발급되고, APP은 발급되지 않는다. 관리자만 호출 가능.", - security = @SecurityRequirement(name = "bearerAuth")) - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "201", - description = "클라이언트 등록 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = - "| 코드 | 메시지 |\n" - + "|------|--------|\n" - + "| 4015 | redirectUris가 비어있거나 10개 초과 또는 512자 초과 |", - content = @Content), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "403", - description = "| 코드 | 메시지 |\n" + "|------|--------|\n" + "| 403 | 관리자 권한 필요 |", - content = @Content) - }) - ApiResponse> register( - @Parameter(description = "클라이언트 등록 요청 정보", required = true) @RequestBody - ClientRegistrationRequest request, - @Parameter(hidden = true) @Member Long memberId); -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/docs/OAuth2Api.java b/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/docs/OAuth2Api.java deleted file mode 100644 index f5ab493ec..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/docs/OAuth2Api.java +++ /dev/null @@ -1,103 +0,0 @@ -package com.blackcompany.eeos.auth.presentation.docs; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.Map; -import org.springframework.http.ResponseEntity; - -@Tag(name = "OAuth2 인증", description = "Web/App 분리 인증 API") -public interface OAuth2Api { - - @Operation( - summary = "인증 진입점", - description = - "client_id, redirect_uri를 검증하고 로그인 페이지로 리다이렉트한다. " + "APP 클라이언트는 code_challenge가 필수이다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "302", - description = "로그인 페이지로 리다이렉트"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = - "| 코드 | 메시지 |\n" - + "|------|--------|\n" - + "| 4014 | 유효하지 않은 클라이언트 |\n" - + "| 4015 | 등록되지 않은 redirect URI |\n" - + "| 4015 | redirect_uri 불일치 시 redirect 없이 즉시 반환 |", - content = @Content) - }) - ResponseEntity authorize( - @Parameter(description = "클라이언트 ID", required = true) String clientId, - @Parameter(description = "리다이렉트 URI", required = true) String redirectUri, - @Parameter(description = "응답 타입 (code만 지원)", required = true) String responseType, - @Parameter(description = "CSRF 방지용 상태값", required = true) String state, - @Parameter(description = "PKCE code_challenge (APP 필수)") String codeChallenge, - @Parameter(description = "PKCE 방식 (S256만 지원)") String codeChallengeMethod); - - @Operation( - summary = "OAuth2 로그인", - description = - "credentials를 검증하고 clientType에 따라 분기한다. " - + "WEB: eeos_access_token / eeos_refresh_token 쿠키 설정 후 303 redirect. " - + "APP: authorization_code 발급 후 303 redirect. " - + "credentials 실패 시 로그인 페이지로 303 redirect (error=invalid_credentials).") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "303", - description = "인증 성공 후 redirect_uri로 리다이렉트"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = - "| 코드 | 메시지 |\n" - + "|------|--------|\n" - + "| 4014 | 유효하지 않은 클라이언트 |\n" - + "| 4015 | 등록되지 않은 redirect URI |", - content = @Content), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "429", - description = "| 코드 | 메시지 |\n" + "|------|--------|\n" + "| 4290 | 로그인 시도 횟수를 초과했습니다 |", - content = @Content) - }) - ResponseEntity login( - @Parameter(description = "클라이언트 ID", required = true) String clientId, - @Parameter(description = "리다이렉트 URI", required = true) String redirectUri, - @Parameter(description = "CSRF 방지용 상태값", required = true) String state, - @Parameter(description = "이메일", required = true) String email, - @Parameter(description = "비밀번호", required = true) String password, - @Parameter(description = "PKCE code_challenge (APP 필수)") String codeChallenge, - @Parameter(description = "PKCE 방식 (S256만 지원, APP 필수)") String codeChallengeMethod, - HttpServletRequest request, - HttpServletResponse response); - - @Operation( - summary = "토큰 교환 (App 전용)", - description = - "authorization_code를 AT/RT로 교환한다. " - + "Content-Type: application/x-www-form-urlencoded. " - + "PKCE code_verifier 검증 필수.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "200", - description = "토큰 발급 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "400", - description = - "| 코드 | 메시지 |\n" - + "|------|--------|\n" - + "| 4014 | 유효하지 않은 클라이언트 (client_id 불일치) |\n" - + "| 4016 | 유효하지 않은 인가 코드 (만료/재사용/PKCE 실패/redirect_uri 불일치) |", - content = @Content) - }) - ResponseEntity> token( - @Parameter(description = "grant_type (authorization_code만 지원)", required = true) - String grantType, - @Parameter(description = "authorization code", required = true) String code, - @Parameter(description = "PKCE code_verifier", required = true) String codeVerifier, - @Parameter(description = "리다이렉트 URI (인가 요청 시와 동일해야 함)", required = true) String redirectUri, - @Parameter(description = "클라이언트 ID", required = true) String clientId); -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/support/MemberArgumentResolver.java b/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/support/MemberArgumentResolver.java index 1ca12817c..c9c74ded1 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/support/MemberArgumentResolver.java +++ b/eeos/src/main/java/com/blackcompany/eeos/auth/presentation/support/MemberArgumentResolver.java @@ -1,11 +1,9 @@ package com.blackcompany.eeos.auth.presentation.support; -import com.blackcompany.eeos.auth.application.domain.token.TokenResolver; import com.blackcompany.eeos.auth.application.exception.NotFoundHeaderTokenException; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Qualifier; +import com.blackcompany.eeos.config.security.JwtAuthentication; import org.springframework.core.MethodParameter; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; @@ -13,16 +11,7 @@ import org.springframework.web.method.support.ModelAndViewContainer; @Component -@Slf4j public class MemberArgumentResolver implements HandlerMethodArgumentResolver { - private final TokenExtractor tokenExtractor; - private final TokenResolver tokenResolver; - - public MemberArgumentResolver( - @Qualifier("header") TokenExtractor tokenExtractor, TokenResolver tokenResolver) { - this.tokenExtractor = tokenExtractor; - this.tokenResolver = tokenResolver; - } @Override public boolean supportsParameter(MethodParameter parameter) { @@ -35,16 +24,15 @@ public Object resolveArgument( ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); - String token = tokenExtractor.extract(request); + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication instanceof JwtAuthentication jwtAuth) { + return jwtAuth.getPrincipal(); + } - if (token == null) { - Member annotation = parameter.getParameterAnnotation(Member.class); - if (annotation.required()) { - throw new NotFoundHeaderTokenException(); - } - return null; + Member annotation = parameter.getParameterAnnotation(Member.class); + if (annotation.required()) { + throw new NotFoundHeaderTokenException(); } - return tokenResolver.getUserDataByAccessToken(token); + return null; } } diff --git a/eeos/src/main/java/com/blackcompany/eeos/common/exception/GlobalExceptionHandler.java b/eeos/src/main/java/com/blackcompany/eeos/common/exception/GlobalExceptionHandler.java index d19d984a1..b0985a961 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/common/exception/GlobalExceptionHandler.java +++ b/eeos/src/main/java/com/blackcompany/eeos/common/exception/GlobalExceptionHandler.java @@ -87,6 +87,14 @@ protected ApiResponse handleRequiredSignupInfo(RequiredSignupInfoEx return ApiResponseGenerator.fail(e.getMessage(), e.getCode(), e.getHttpStatus(), headers); } + /** 잘못된 인자값 예외 — enum 변환 실패 등 */ + @ExceptionHandler(IllegalArgumentException.class) + protected ApiResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("IllegalArgumentException", e); + String code = String.valueOf(HttpStatus.BAD_REQUEST.value()); + return ApiResponseGenerator.fail(e.getMessage(), code, HttpStatus.BAD_REQUEST); + } + /** 나머지 예외 발생 */ @ExceptionHandler(Exception.class) protected ApiResponse handleException(Exception e) { diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/security/AccessTokenFilter.java b/eeos/src/main/java/com/blackcompany/eeos/config/security/AccessTokenFilter.java deleted file mode 100644 index b7d55ce0b..000000000 --- a/eeos/src/main/java/com/blackcompany/eeos/config/security/AccessTokenFilter.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.blackcompany.eeos.config.security; - -import com.blackcompany.eeos.auth.application.domain.token.TokenResolver; -import com.blackcompany.eeos.auth.application.exception.NotFoundHeaderTokenException; -import com.blackcompany.eeos.auth.presentation.support.AuthConstants; -import com.blackcompany.eeos.auth.presentation.support.TokenExtractor; -import io.jsonwebtoken.JwtException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import java.util.Optional; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -public class AccessTokenFilter extends OncePerRequestFilter { - - private final TokenExtractor headerExtractor; - private final TokenResolver tokenResolver; - - public AccessTokenFilter( - @Qualifier("header") TokenExtractor headerExtractor, TokenResolver tokenResolver) { - this.headerExtractor = headerExtractor; - this.tokenResolver = tokenResolver; - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - try { - String token = extractToken(request); - - createAuthentication(token) - .ifPresentOrElse(this::setAuthentication, SecurityContextHolder::clearContext); - - filterChain.doFilter(request, response); - } catch (NotFoundHeaderTokenException | JwtException e) { - SecurityContextHolder.clearContext(); - filterChain.doFilter(request, response); - } - } - - private String extractToken(HttpServletRequest request) { - try { - return headerExtractor.extract(request); - } catch (NotFoundHeaderTokenException e) { - return extractFromCookie(request); - } - } - - private String extractFromCookie(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies != null) { - for (Cookie cookie : cookies) { - if (AuthConstants.ACCESS_TOKEN_KEY.equals(cookie.getName())) { - return cookie.getValue(); - } - } - } - throw new NotFoundHeaderTokenException(); - } - - private Optional createAuthentication(String token) { - Optional memberId = parseToken(token); - - return memberId.map( - id -> - new JwtAuthentication( - id, parseRole(token).stream().map(SimpleGrantedAuthority::new).toList())); - } - - private List parseRole(String token) { - return tokenResolver.getRoles(token); - } - - private Optional parseToken(String token) { - try { - return Optional.of(tokenResolver.getUserDataByAccessToken(token)); - } catch (Exception e) { - return Optional.empty(); - } - } - - private void setAuthentication(JwtAuthentication authentication) { - SecurityContextHolder.getContext().setAuthentication(authentication); - } -} diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/security/InternalApiKeyFilter.java b/eeos/src/main/java/com/blackcompany/eeos/config/security/InternalApiKeyFilter.java index 1ca3f1b34..7b2adf761 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/config/security/InternalApiKeyFilter.java +++ b/eeos/src/main/java/com/blackcompany/eeos/config/security/InternalApiKeyFilter.java @@ -17,6 +17,12 @@ public class InternalApiKeyFilter extends OncePerRequestFilter { @Value("${eeos.internal-api-key}") private String internalApiKey; + @Override + protected boolean shouldNotFilter(jakarta.servlet.http.HttpServletRequest request) { + // SecurityConfig 없이 직접 서블릿 필터로 등록될 때 /api/internal/** 이외 경로는 스킵 + return !request.getRequestURI().startsWith("/api/internal/"); + } + @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain chain) diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/security/PassportAuthenticationFilter.java b/eeos/src/main/java/com/blackcompany/eeos/config/security/PassportAuthenticationFilter.java new file mode 100644 index 000000000..4db27a61b --- /dev/null +++ b/eeos/src/main/java/com/blackcompany/eeos/config/security/PassportAuthenticationFilter.java @@ -0,0 +1,77 @@ +package com.blackcompany.eeos.config.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Gateway가 주입한 X-User-Passport 헤더를 읽어 SecurityContext를 설정하는 필터. + * + *

Passport roles("USER", "ADMIN")에 "ROLE_" 접두사를 붙여 Spring Security hasAnyRole()과 호환되도록 한다. + */ +@Slf4j +public class PassportAuthenticationFilter extends OncePerRequestFilter { + + static final String PASSPORT_HEADER = "X-User-Passport"; + private static final ObjectMapper mapper = + new ObjectMapper().registerModule(new JavaTimeModule()); + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String passportHeader = request.getHeader(PASSPORT_HEADER); + if (passportHeader != null && !passportHeader.isBlank()) { + try { + byte[] decoded = Base64.getDecoder().decode(passportHeader); + @SuppressWarnings("unchecked") + Map claims = mapper.readValue(decoded, Map.class); + + Long memberId = toLong(claims.get("memberId")); + if (memberId != null) { + List roles = toStringList(claims.get("roles")); + JwtAuthentication auth = + new JwtAuthentication( + memberId, + roles.stream() + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .map(SimpleGrantedAuthority::new) + .toList()); + SecurityContextHolder.getContext().setAuthentication(auth); + log.debug("Passport 인증 설정: memberId={}", memberId); + } + } catch (Exception e) { + log.warn("X-User-Passport 파싱 실패: {}", e.getMessage()); + } + } + chain.doFilter(request, response); + } + + private Long toLong(Object value) { + if (value instanceof Number n) return n.longValue(); + if (value instanceof String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException ignored) { + } + } + return null; + } + + @SuppressWarnings("unchecked") + private List toStringList(Object value) { + if (value instanceof List list) return (List) list; + return List.of(); + } +} diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityConfig.java b/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityConfig.java index daf19a613..ea31e8f96 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityConfig.java +++ b/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityConfig.java @@ -7,14 +7,6 @@ @Configuration public class SecurityConfig { - @Bean - public FilterRegistrationBean auth(AccessTokenFilter authFilter) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(authFilter); - registrationBean.setEnabled(false); - return registrationBean; - } - @Bean public FilterRegistrationBean optionsFilterRegistrationBean( OptionsFilter optionsFilter) { diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityFilterChainConfig.java b/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityFilterChainConfig.java index d29db6dbc..f6dfbaeda 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityFilterChainConfig.java +++ b/eeos/src/main/java/com/blackcompany/eeos/config/security/SecurityFilterChainConfig.java @@ -18,7 +18,6 @@ public class SecurityFilterChainConfig { private static final String ADMIN = Role.ROLE_ADMIN.getRole(); - private final AccessTokenFilter authFilter; private final OptionsFilter optionsFilter; private final DynamicCorsConfigurationSource corsConfigurationSource; private final AccessTokenEntryPoint accessTokenEntryPoint; @@ -74,11 +73,6 @@ SecurityFilterChain nonAuthenticated(HttpSecurity httpSecurity) throws Exception .requestMatchers(HttpMethod.POST, "/api/slack/events") // v1 .requestMatchers(HttpMethod.POST, "/api/v1/auth/login") - // v2 (OAuth2 흐름) - .requestMatchers(HttpMethod.GET, "/api/v2/auth/authorize") - .requestMatchers(HttpMethod.POST, "/api/v2/auth/login") - .requestMatchers(HttpMethod.POST, "/api/v2/auth/token") - .requestMatchers(HttpMethod.POST, "/api/v2/auth/clients") .requestMatchers("/api/guest/**") .requestMatchers("/api/health-check") .requestMatchers("/actuator/prometheus") @@ -150,7 +144,9 @@ SecurityFilterChain authenticated(HttpSecurity httpSecurity) throws Exception { httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource)); - httpSecurity.addFilterAt(authFilter, LogoutFilter.class); + // PassportFilter: @Component 없이 Security 체인에만 등록 + // SecurityContextHolderFilter 이후에 실행되어야 리셋 문제가 없음 + httpSecurity.addFilterBefore(new PassportAuthenticationFilter(), LogoutFilter.class); httpSecurity.exceptionHandling(ex -> ex.authenticationEntryPoint(accessTokenEntryPoint)); httpSecurity.addFilterAfter(optionsFilter, CorsFilter.class); diff --git a/eeos/src/main/java/com/blackcompany/eeos/config/security/UnknownEndpointFilter.java b/eeos/src/main/java/com/blackcompany/eeos/config/security/UnknownEndpointFilter.java index 86f8517b9..4f5c7a458 100644 --- a/eeos/src/main/java/com/blackcompany/eeos/config/security/UnknownEndpointFilter.java +++ b/eeos/src/main/java/com/blackcompany/eeos/config/security/UnknownEndpointFilter.java @@ -22,6 +22,13 @@ public UnknownEndpointFilter( this.mapping = mapping; } + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // AccessTokenFilter 또는 다른 Security 체인이 이미 이 요청을 처리한 경우 스킵 + // (직접 서블릿 필터로 등록될 때 인증된 요청을 방해하지 않도록) + return request.getAttribute("eeos.securityChainProcessed") != null; + } + @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) diff --git a/eeos/src/main/resources/db/migration/V1.00.0.8__drop_oauth_client_tables.sql b/eeos/src/main/resources/db/migration/V1.00.0.8__drop_oauth_client_tables.sql new file mode 100644 index 000000000..8fbb2deb3 --- /dev/null +++ b/eeos/src/main/resources/db/migration/V1.00.0.8__drop_oauth_client_tables.sql @@ -0,0 +1,8 @@ +-- OAuth 클라이언트 관리를 auth-api(Spring Authorization Server)로 이전 +-- EEOS-BE 자체 OAuth2 서버 기능 제거에 따라 관련 테이블 삭제 + +ALTER TABLE oauth_client_redirect_uri + DROP FOREIGN KEY fk_redirect_uri_client; + +DROP TABLE IF EXISTS oauth_client_redirect_uri; +DROP TABLE IF EXISTS oauth_client; diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/ClientTypeTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/ClientTypeTest.java deleted file mode 100644 index 92e1fc6f7..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/ClientTypeTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.blackcompany.eeos.auth.application.domain; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class ClientTypeTest { - - @Test - @DisplayName("should return true when WEB is confidential") - void shouldReturnTrueWhenWebIsConfidential() { - // Given - ClientType clientType = ClientType.WEB; - - // When - boolean result = clientType.isConfidential(); - - // Then - assertTrue(result); - } - - @Test - @DisplayName("should return false when APP is confidential") - void shouldReturnFalseWhenAppIsConfidential() { - // Given - ClientType clientType = ClientType.APP; - - // When - boolean result = clientType.isConfidential(); - - // Then - assertFalse(result); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/PkceValidatorTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/PkceValidatorTest.java deleted file mode 100644 index 92585e6ab..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/PkceValidatorTest.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.blackcompany.eeos.auth.application.domain; - -import static org.junit.jupiter.api.Assertions.*; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class PkceValidatorTest { - - @Test - @DisplayName("should return true when code_verifier matches code_challenge") - void shouldReturnTrueWhenVerifierMatchesChallenge() throws Exception { - // Given - String codeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; - byte[] digest = - MessageDigest.getInstance("SHA-256") - .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); - String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - - // When & Then - assertTrue(PkceValidator.validate(codeVerifier, codeChallenge, "S256")); - } - - @Test - @DisplayName("should return false when code_verifier does not match") - void shouldReturnFalseWhenVerifierDoesNotMatch() { - // When & Then - assertFalse(PkceValidator.validate("wrong-verifier", "some-challenge", "S256")); - } - - @Test - @DisplayName("should throw when unsupported method") - void shouldThrowWhenUnsupportedMethod() { - // When & Then - assertThrows( - IllegalArgumentException.class, - () -> PkceValidator.validate("verifier", "challenge", "plain")); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/token/TokenProviderTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/token/TokenProviderTest.java deleted file mode 100644 index acea18439..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/application/domain/token/TokenProviderTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.blackcompany.eeos.auth.application.domain.token; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class TokenProviderTest { - - private TokenProvider tokenProvider; - private TokenResolver tokenResolver; - - @BeforeEach - void setUp() { - String accessKey = "test-access-secret-key-must-be-at-least-32-characters-long!!"; - String refreshKey = "test-refresh-secret-key-must-be-at-least-32-characters-long!"; - long accessValidTime = 3600000L; - long refreshValidTime = 86400000L; - - tokenProvider = new TokenProvider(accessKey, refreshKey, accessValidTime, refreshValidTime); - tokenResolver = new TokenResolver(accessKey, refreshKey); - } - - @Test - @DisplayName("should create refresh token with clientType and clientId claims") - void shouldCreateRefreshTokenWithClientClaims() { - // When - String rt = tokenProvider.createRefreshToken(1L, "WEB", "test-client-id"); - - // Then - assertEquals("WEB", tokenResolver.getClientTypeByRefreshToken(rt)); - assertEquals("test-client-id", tokenResolver.getClientIdByRefreshToken(rt)); - assertEquals(1L, tokenResolver.getUserDataByRefreshToken(rt)); - } - - @Test - @DisplayName("should create refresh token for APP with clientType APP") - void shouldCreateRefreshTokenForApp() { - // When - String rt = tokenProvider.createRefreshToken(2L, "APP", "app-client-id"); - - // Then - assertEquals("APP", tokenResolver.getClientTypeByRefreshToken(rt)); - assertEquals("app-client-id", tokenResolver.getClientIdByRefreshToken(rt)); - } - - @Test - @DisplayName("should return null clientType for legacy refresh token") - void shouldReturnNullClientTypeForLegacyRefreshToken() { - // Given — legacy RT without client claims - String rt = tokenProvider.createRefreshToken(3L); - - // When - String clientType = tokenResolver.getClientTypeByRefreshToken(rt); - - // Then - assertNull(clientType); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/ClientServiceTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/ClientServiceTest.java deleted file mode 100644 index b9b5e8a22..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/ClientServiceTest.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.blackcompany.eeos.auth.application.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.exception.InvalidClientException; -import com.blackcompany.eeos.auth.application.exception.InvalidRedirectUriException; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.auth.persistence.client.ClientRepository; -import java.util.Optional; -import java.util.Set; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class ClientServiceTest { - - @Mock ClientRepository clientRepository; - @InjectMocks ClientService clientService; - - @Test - @DisplayName("should register WEB client with secret") - void shouldRegisterWebClientWithSecret() { - // Given - when(clientRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - - // When - var result = - clientService.register( - "EEOS-Web", ClientType.WEB, Set.of("https://eeos.econovation.kr/callback")); - - // Then - assertNotNull(result.clientId()); - assertNotNull(result.clientSecret()); - } - - @Test - @DisplayName("should register APP client without secret") - void shouldRegisterAppClientWithoutSecret() { - // Given - when(clientRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - - // When - var result = - clientService.register( - "EEOS-App", ClientType.APP, Set.of("kr.econovation.eeos://callback")); - - // Then - assertNotNull(result.clientId()); - assertNull(result.clientSecret()); - } - - @Test - @DisplayName("should throw when redirectUris is empty") - void shouldThrowWhenRedirectUrisIsEmpty() { - // When & Then - assertThrows( - InvalidRedirectUriException.class, - () -> clientService.register("Test", ClientType.WEB, Set.of())); - } - - @Test - @DisplayName("should throw when redirectUris exceeds 10") - void shouldThrowWhenRedirectUrisExceedsTen() { - // Given - Set uris = Set.of("u1", "u2", "u3", "u4", "u5", "u6", "u7", "u8", "u9", "u10", "u11"); - - // When & Then - assertThrows( - InvalidRedirectUriException.class, - () -> clientService.register("Test", ClientType.WEB, uris)); - } - - @Test - @DisplayName("should throw when redirectUri exceeds 512 chars") - void shouldThrowWhenRedirectUriExceedsMaxLength() { - // Given - String longUri = "https://example.com/" + "a".repeat(500); - - // When & Then - assertThrows( - InvalidRedirectUriException.class, - () -> clientService.register("Test", ClientType.WEB, Set.of(longUri))); - } - - @Test - @DisplayName("should find client and validate redirectUri") - void shouldFindClientAndValidateRedirectUri() { - // Given - ClientEntity entity = - ClientEntity.builder() - .clientId("test-uuid") - .clientType(ClientType.WEB) - .clientName("Test") - .build(); - entity.addRedirectUri("https://example.com/callback"); - when(clientRepository.findByClientIdWithRedirectUris("test-uuid")) - .thenReturn(Optional.of(entity)); - - // When - ClientEntity found = - clientService.findAndValidateRedirectUri("test-uuid", "https://example.com/callback"); - - // Then - assertEquals("test-uuid", found.getClientId()); - } - - @Test - @DisplayName("should throw when clientId not found") - void shouldThrowWhenClientIdNotFound() { - // Given - when(clientRepository.findByClientIdWithRedirectUris("unknown")).thenReturn(Optional.empty()); - - // When & Then - assertThrows( - InvalidClientException.class, - () -> clientService.findAndValidateRedirectUri("unknown", "https://any.com")); - } - - @Test - @DisplayName("should throw when redirectUri not registered") - void shouldThrowWhenRedirectUriNotRegistered() { - // Given - ClientEntity entity = - ClientEntity.builder() - .clientId("test-uuid") - .clientType(ClientType.WEB) - .clientName("Test") - .build(); - entity.addRedirectUri("https://registered.com/callback"); - when(clientRepository.findByClientIdWithRedirectUris("test-uuid")) - .thenReturn(Optional.of(entity)); - - // When & Then - assertThrows( - InvalidRedirectUriException.class, - () -> - clientService.findAndValidateRedirectUri( - "test-uuid", "https://unregistered.com/callback")); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/OAuth2LoginServiceTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/OAuth2LoginServiceTest.java deleted file mode 100644 index 4214baa94..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/OAuth2LoginServiceTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.blackcompany.eeos.auth.application.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -import com.blackcompany.eeos.auth.application.domain.AuthorizationCodeData; -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.domain.TokenModel; -import com.blackcompany.eeos.auth.application.support.AuthenticationTokenGenerator; -import com.blackcompany.eeos.auth.application.support.LoginRateLimiter; -import com.blackcompany.eeos.auth.persistence.AuthorizationCodeRepository; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.member.application.model.MemberModel; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class OAuth2LoginServiceTest { - - @Mock AuthService authService; - @Mock ClientService clientService; - @Mock AuthenticationTokenGenerator tokenGenerator; - @Mock AuthorizationCodeRepository codeRepository; - @Mock LoginRateLimiter rateLimiter; - @InjectMocks OAuth2LoginService oAuth2LoginService; - - @Test - @DisplayName("should return TokenModel for WEB client login") - void shouldReturnTokenModelForWebLogin() { - // Given - ClientEntity client = - ClientEntity.builder().clientId("web-client-id").clientType(ClientType.WEB).build(); - client.addRedirectUri("https://eeos.econovation.kr/callback"); - - when(clientService.findAndValidateRedirectUri( - "web-client-id", "https://eeos.econovation.kr/callback")) - .thenReturn(client); - - MemberModel memberModel = MemberModel.builder().name("test").build(); - // MemberModel.id는 private이므로 reflection 없이 getMemberId()를 mock할 수 없음 - // 대신 AllArgsConstructor를 사용 - MemberModel member = new MemberModel(1L, "test", null, false, null, null); - when(authService.authenticate("user@test.com", "password")).thenReturn(member); - - when(tokenGenerator.execute(1L, "WEB", "web-client-id")) - .thenReturn( - TokenModel.builder() - .accessToken("at") - .refreshToken("rt") - .accessExpiredTime(100L) - .refreshExpiredTime(200L) - .build()); - - // When - TokenModel result = - oAuth2LoginService.loginForWeb( - "web-client-id", - "https://eeos.econovation.kr/callback", - "user@test.com", - "password", - "127.0.0.1"); - - // Then - assertNotNull(result.getAccessToken()); - verify(rateLimiter).resetAccountCounter("user@test.com"); - } - - @Test - @DisplayName("should return authorization code for APP client login") - void shouldReturnAuthorizationCodeForAppLogin() { - // Given - ClientEntity client = - ClientEntity.builder().clientId("app-client-id").clientType(ClientType.APP).build(); - client.addRedirectUri("kr.econovation.eeos://callback"); - - when(clientService.findAndValidateRedirectUri( - "app-client-id", "kr.econovation.eeos://callback")) - .thenReturn(client); - - MemberModel member = new MemberModel(2L, "test", null, false, null, null); - when(authService.authenticate("user@test.com", "password")).thenReturn(member); - - when(codeRepository.save(any(AuthorizationCodeData.class))).thenReturn("generated-code"); - - // When - String code = - oAuth2LoginService.loginForApp( - "app-client-id", - "kr.econovation.eeos://callback", - "user@test.com", - "password", - "127.0.0.1", - "code-challenge-value", - "S256"); - - // Then - assertEquals("generated-code", code); - verify(rateLimiter).resetAccountCounter("user@test.com"); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/TokenExchangeServiceTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/TokenExchangeServiceTest.java deleted file mode 100644 index d67c18cbf..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/application/service/TokenExchangeServiceTest.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.blackcompany.eeos.auth.application.service; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import com.blackcompany.eeos.auth.application.domain.AuthorizationCodeData; -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.domain.TokenModel; -import com.blackcompany.eeos.auth.application.exception.InvalidClientException; -import com.blackcompany.eeos.auth.application.exception.InvalidGrantException; -import com.blackcompany.eeos.auth.application.support.AuthenticationTokenGenerator; -import com.blackcompany.eeos.auth.persistence.AuthorizationCodeRepository; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.auth.persistence.client.ClientRepository; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; -import java.util.Optional; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class TokenExchangeServiceTest { - - @Mock AuthorizationCodeRepository codeRepository; - @Mock ClientRepository clientRepository; - @Mock AuthenticationTokenGenerator tokenGenerator; - @InjectMocks TokenExchangeService tokenExchangeService; - - @Test - @DisplayName("should exchange code for tokens when PKCE valid") - void shouldExchangeCodeForTokens() throws Exception { - // Given - String codeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; - byte[] digest = - MessageDigest.getInstance("SHA-256") - .digest(codeVerifier.getBytes(StandardCharsets.US_ASCII)); - String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - - AuthorizationCodeData codeData = - AuthorizationCodeData.builder() - .memberId(1L) - .clientId("app-client-id") - .codeChallenge(codeChallenge) - .codeChallengeMethod("S256") - .redirectUri("kr.econovation.eeos://callback") - .build(); - - when(codeRepository.findAndDelete("test-code")).thenReturn(Optional.of(codeData)); - when(clientRepository.findByClientIdWithRedirectUris("app-client-id")) - .thenReturn( - Optional.of( - ClientEntity.builder() - .clientId("app-client-id") - .clientType(ClientType.APP) - .build())); - when(tokenGenerator.execute(1L, "APP", "app-client-id")) - .thenReturn( - TokenModel.builder() - .accessToken("at") - .refreshToken("rt") - .accessExpiredTime(100L) - .refreshExpiredTime(200L) - .build()); - - // When - TokenModel result = - tokenExchangeService.exchange( - "test-code", codeVerifier, "kr.econovation.eeos://callback", "app-client-id"); - - // Then - assertEquals("at", result.getAccessToken()); - } - - @Test - @DisplayName("should throw when code expired or not found") - void shouldThrowWhenCodeNotFound() { - // Given - when(codeRepository.findAndDelete("expired-code")).thenReturn(Optional.empty()); - - // When & Then - assertThrows( - InvalidGrantException.class, - () -> tokenExchangeService.exchange("expired-code", "verifier", "uri", "client-id")); - } - - @Test - @DisplayName("should throw when client_id mismatch") - void shouldThrowWhenClientIdMismatch() { - // Given - AuthorizationCodeData codeData = - AuthorizationCodeData.builder().clientId("real-client-id").build(); - when(codeRepository.findAndDelete("code")).thenReturn(Optional.of(codeData)); - - // When & Then - assertThrows( - InvalidClientException.class, - () -> tokenExchangeService.exchange("code", "verifier", "uri", "wrong-client-id")); - } - - @Test - @DisplayName("should throw when redirect_uri mismatch") - void shouldThrowWhenRedirectUriMismatch() { - // Given - AuthorizationCodeData codeData = - AuthorizationCodeData.builder() - .clientId("client-id") - .redirectUri("https://registered.com/callback") - .build(); - when(codeRepository.findAndDelete("code")).thenReturn(Optional.of(codeData)); - - // When & Then - assertThrows( - InvalidGrantException.class, - () -> - tokenExchangeService.exchange( - "code", "verifier", "https://other.com/callback", "client-id")); - } - - @Test - @DisplayName("should throw when PKCE verification fails") - void shouldThrowWhenPkceVerificationFails() { - // Given - AuthorizationCodeData codeData = - AuthorizationCodeData.builder() - .clientId("client-id") - .redirectUri("https://app.com/callback") - .codeChallenge("valid-challenge") - .codeChallengeMethod("S256") - .build(); - when(codeRepository.findAndDelete("code")).thenReturn(Optional.of(codeData)); - - // When & Then - assertThrows( - InvalidGrantException.class, - () -> - tokenExchangeService.exchange( - "code", "wrong-verifier", "https://app.com/callback", "client-id")); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/auth/presentation/controller/OAuth2ControllerTest.java b/eeos/src/test/java/com/blackcompany/eeos/auth/presentation/controller/OAuth2ControllerTest.java deleted file mode 100644 index 8c04dc0b6..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/auth/presentation/controller/OAuth2ControllerTest.java +++ /dev/null @@ -1,233 +0,0 @@ -package com.blackcompany.eeos.auth.presentation.controller; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import com.blackcompany.eeos.auth.application.domain.ClientType; -import com.blackcompany.eeos.auth.application.domain.TokenModel; -import com.blackcompany.eeos.auth.application.exception.InvalidClientException; -import com.blackcompany.eeos.auth.application.service.ClientService; -import com.blackcompany.eeos.auth.application.service.OAuth2LoginService; -import com.blackcompany.eeos.auth.application.service.TokenExchangeService; -import com.blackcompany.eeos.auth.persistence.client.ClientEntity; -import com.blackcompany.eeos.auth.presentation.support.AuthCookieManager; -import java.lang.reflect.Field; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; - -@ExtendWith(MockitoExtension.class) -class OAuth2ControllerTest { - - @Mock private ClientService clientService; - @Mock private OAuth2LoginService oAuth2LoginService; - @Mock private TokenExchangeService tokenExchangeService; - @Mock private AuthCookieManager cookieManager; - - @InjectMocks private OAuth2Controller controller; - - @BeforeEach - void setUp() throws Exception { - Field loginPageUrlField = OAuth2Controller.class.getDeclaredField("loginPageUrl"); - loginPageUrlField.setAccessible(true); - loginPageUrlField.set(controller, "http://localhost:3000/login"); - } - - @Nested - @DisplayName("/authorize 엔드포인트") - class Authorize { - - @Test - @DisplayName("response_type이 code가 아니면 400 반환") - void bad_request_when_invalid_response_type() { - ResponseEntity result = - controller.authorize("client1", "http://app/callback", "token", "state1", null, null); - - assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); - } - - @Test - @DisplayName("APP 클라이언트가 code_challenge 없이 요청하면 400 반환") - void bad_request_when_app_without_code_challenge() { - ClientEntity appClient = - ClientEntity.builder().clientId("app1").clientType(ClientType.APP).build(); - when(clientService.findAndValidateRedirectUri("app1", "http://app/callback")) - .thenReturn(appClient); - - ResponseEntity result = - controller.authorize("app1", "http://app/callback", "code", "state1", null, null); - - assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); - } - - @Test - @DisplayName("유효한 요청이면 302 로그인 페이지 리다이렉트") - void redirect_to_login_page() { - ClientEntity webClient = - ClientEntity.builder().clientId("web1").clientType(ClientType.WEB).build(); - when(clientService.findAndValidateRedirectUri("web1", "http://web/callback")) - .thenReturn(webClient); - - ResponseEntity result = - controller.authorize("web1", "http://web/callback", "code", "state1", null, null); - - assertEquals(HttpStatus.FOUND, result.getStatusCode()); - assertTrue(result.getHeaders().getLocation().toString().contains("client_id=web1")); - } - } - - @Nested - @DisplayName("/login/oauth2 엔드포인트") - class LoginOAuth2 { - - @Test - @DisplayName("유효하지 않은 client_id면 400 반환") - void bad_request_when_invalid_client() { - when(clientService.findAndValidateRedirectUri("bad", "http://x")) - .thenThrow(new InvalidClientException()); - - ResponseEntity result = - controller.login( - "bad", - "http://x", - "state", - "e", - "p", - null, - null, - new MockHttpServletRequest(), - new MockHttpServletResponse()); - - assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); - } - - @Test - @DisplayName("WEB 클라이언트 로그인 성공 시 쿠키 설정 + 303 리다이렉트") - void web_login_sets_cookies_and_redirects() { - ClientEntity webClient = - ClientEntity.builder().clientId("web1").clientType(ClientType.WEB).build(); - when(clientService.findAndValidateRedirectUri("web1", "http://web/callback")) - .thenReturn(webClient); - - TokenModel tokenModel = - TokenModel.builder() - .accessToken("at") - .refreshToken("rt") - .accessExpiredTime(99999L) - .build(); - when(oAuth2LoginService.loginForWeb( - "web1", "http://web/callback", "user@test.com", "pw", "127.0.0.1")) - .thenReturn(tokenModel); - when(cookieManager.setAccessTokenCookie("at")) - .thenReturn(ResponseCookie.from("eeos_access_token", "at").build()); - when(cookieManager.setRefreshTokenCookie("rt")) - .thenReturn(ResponseCookie.from("eeos_refresh_token", "rt").build()); - - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRemoteAddr("127.0.0.1"); - MockHttpServletResponse response = new MockHttpServletResponse(); - - ResponseEntity result = - controller.login( - "web1", - "http://web/callback", - "state1", - "user@test.com", - "pw", - null, - null, - request, - response); - - assertEquals(HttpStatus.SEE_OTHER, result.getStatusCode()); - assertTrue( - result.getHeaders().get(HttpHeaders.LOCATION).get(0).contains("http://web/callback")); - assertTrue(response.getHeader(HttpHeaders.SET_COOKIE).contains("eeos_access_token")); - } - - @Test - @DisplayName("APP 클라이언트 로그인 성공 시 authorization_code + 303 리다이렉트") - void app_login_returns_code_and_redirects() { - ClientEntity appClient = - ClientEntity.builder().clientId("app1").clientType(ClientType.APP).build(); - when(clientService.findAndValidateRedirectUri("app1", "http://app/callback")) - .thenReturn(appClient); - when(oAuth2LoginService.loginForApp( - "app1", - "http://app/callback", - "user@test.com", - "pw", - "127.0.0.1", - "challenge", - "S256")) - .thenReturn("auth-code-123"); - - MockHttpServletRequest request = new MockHttpServletRequest(); - request.setRemoteAddr("127.0.0.1"); - - ResponseEntity result = - controller.login( - "app1", - "http://app/callback", - "state1", - "user@test.com", - "pw", - "challenge", - "S256", - request, - new MockHttpServletResponse()); - - assertEquals(HttpStatus.SEE_OTHER, result.getStatusCode()); - assertTrue( - result.getHeaders().get(HttpHeaders.LOCATION).get(0).contains("code=auth-code-123")); - } - } - - @Nested - @DisplayName("/token 엔드포인트") - class Token { - - @Test - @DisplayName("grant_type이 authorization_code가 아니면 400 반환") - void bad_request_when_invalid_grant_type() { - ResponseEntity> result = - controller.token("password", "code", "verifier", "http://x", "client1"); - - assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); - } - - @Test - @DisplayName("유효한 code exchange 시 토큰 반환") - void exchange_returns_tokens() { - TokenModel tokenModel = - TokenModel.builder() - .accessToken("new-at") - .refreshToken("new-rt") - .accessExpiredTime(System.currentTimeMillis() + 3600000) - .build(); - when(tokenExchangeService.exchange("code123", "verifier", "http://app/callback", "app1")) - .thenReturn(tokenModel); - - ResponseEntity> result = - controller.token( - "authorization_code", "code123", "verifier", "http://app/callback", "app1"); - - assertEquals(HttpStatus.OK, result.getStatusCode()); - assertEquals("new-at", result.getBody().get("access_token")); - assertEquals("new-rt", result.getBody().get("refresh_token")); - assertEquals("Bearer", result.getBody().get("token_type")); - } - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/config/SpringSecurityFilterChainTest.java b/eeos/src/test/java/com/blackcompany/eeos/config/SpringSecurityFilterChainTest.java index c90daaa5b..ecd259c1a 100644 --- a/eeos/src/test/java/com/blackcompany/eeos/config/SpringSecurityFilterChainTest.java +++ b/eeos/src/test/java/com/blackcompany/eeos/config/SpringSecurityFilterChainTest.java @@ -1,24 +1,17 @@ package com.blackcompany.eeos.config; -import static org.mockito.BDDMockito.*; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import com.blackcompany.eeos.auth.application.domain.token.JwtTestUtil; import com.blackcompany.eeos.auth.application.domain.token.TokenProvider; import com.blackcompany.eeos.auth.application.domain.token.TokenResolver; -import com.blackcompany.eeos.auth.application.model.Role; import com.blackcompany.eeos.auth.application.service.AuthService; -import com.blackcompany.eeos.member.application.model.ActiveStatus; -import com.blackcompany.eeos.member.application.model.MemberModel; -import com.blackcompany.eeos.member.fixture.MemberFixture; -import java.sql.Date; -import java.time.Instant; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -26,7 +19,6 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; @@ -42,19 +34,22 @@ class SecurityFilterChainTest { @MockBean private TokenResolver tokenResolver; @MockBean private AuthService authService; + private String passportHeader(long memberId, String... roles) { + String roleJson = "[\"" + String.join("\",\"", roles) + "\"]"; + String json = + String.format( + "{\"memberId\":%d,\"loginId\":\"user%d\",\"name\":\"테스터\",\"generation\":30,\"status\":\"AM\",\"roles\":%s,\"issuedAt\":\"%s\",\"expiresAt\":\"%s\"}", + memberId, memberId, roleJson, LocalDateTime.now(), LocalDateTime.now().plusHours(1)); + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + @Nested @DisplayName("1. 인증 필요 없는 엔드포인트") class NonAuthorizedEndpoints { - private final String id = "user"; - private final String password = "password"; - - private final MemberModel testMember = MemberFixture.멤버_모델(1L, ActiveStatus.AM); - @Test @DisplayName("인증 없이 /api/auth/login 접근 가능 (400 또는 303 반환)") void loginShouldReturn200() throws Exception { - // /api/auth/login은 비인증 엔드포인트이므로 400 또는 303이 반환되어야 한다 (401/403이 아님) mockMvc .perform( post("/api/auth/login") @@ -62,8 +57,8 @@ void loginShouldReturn200() throws Exception { .param("client_id", "unknown") .param("redirect_uri", "http://test.com") .param("state", "test") - .param("email", id) - .param("password", password)) + .param("email", "user") + .param("password", "password")) .andExpect(status().is(org.hamcrest.Matchers.not(401))) .andExpect(status().is(org.hamcrest.Matchers.not(403))); } @@ -78,128 +73,98 @@ void healthCheckShouldReturn200() throws Exception { @DisplayName("2-1. 인증이 필요한 엔드포인트 - 일반 유저") class UserEndpoints { - private final String VALID_JWT = JwtTestUtil.createToken(1L, Role.ROLE_USER); - - @BeforeEach - void setAccessToken() { - given(tokenProvider.createAccessToken(any(), any())).willReturn(VALID_JWT); - - given(tokenResolver.getUserDataByAccessToken(VALID_JWT)).willReturn(1L); - - given(tokenResolver.getExpiredDateByAccessToken(VALID_JWT)) - .willReturn(Date.from(Instant.now()).getTime()); - given(tokenResolver.getRoles(VALID_JWT)).willReturn(List.of(Role.ROLE_USER.getRole())); - } - @Test - @DisplayName("[일반유저] 토큰이 존재하지 않으면 401 반환") - void 일반유저_토큰이_존재하지_않으면_401반환() throws Exception { - + @DisplayName("[일반유저] Passport 헤더가 없으면 401 반환") + void 일반유저_패스포트_없으면_401반환() throws Exception { mockMvc.perform(get("/api/programs")).andExpect(status().isUnauthorized()); } @Test - @DisplayName("[일반유저] 토큰이 있으면 200 반환") - void 일반유저_올바른_토큰이_있으면_200응답() throws Exception { + @DisplayName("[일반유저] Passport 헤더가 있으면 200 반환") + void 일반유저_올바른_패스포트가_있으면_200응답() throws Exception { mockMvc - .perform(get("/api/members?activeStatus=all").header("Authorization", bearerToken())) + .perform( + get("/api/members?activeStatus=all") + .header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isOk()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_1") - void 일반유저_토큰으로_관리자_API_접근시_403응답_1() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_1() throws Exception { mockMvc - .perform(post("/api/programs").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(post("/api/programs").header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_2") - void 일반유저_토큰으로_관리자_API_접근시_403응답_2() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_2() throws Exception { mockMvc - .perform(delete("/api/programs").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(delete("/api/programs").header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_3") - void 일반유저_토큰으로_관리자_API_접근시_403응답_1_3() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_3() throws Exception { mockMvc - .perform(delete("/api/members/1").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(delete("/api/members/1").header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_4") - void 일반유저_토큰으로_관리자_API_접근시_403응답_1_4() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_4() throws Exception { mockMvc .perform( - put("/api/members/activeStatus/1").header(HttpHeaders.AUTHORIZATION, bearerToken())) + put("/api/members/activeStatus/1") + .header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_5") - void 일반유저_토큰으로_관리자_API_접근시_403응답_1_5() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_5() throws Exception { mockMvc - .perform(post("/api/teams").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(post("/api/teams").header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_6") - void 일반유저_토큰으로_관리자_API_접근시_403응답_1_6() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_6() throws Exception { mockMvc - .perform(delete("/api/teams/1").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(delete("/api/teams/1").header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } @Test @DisplayName("[일반유저] 일반 유저 권한은 관리자 API에 접근 불가능_7") - void 일반유저_토큰으로_관리자_API_접근시_403응답_1_7() throws Exception { + void 일반유저_패스포트로_관리자_API_접근시_403응답_7() throws Exception { mockMvc - .perform(get("/api/admin/test").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(get("/api/admin/test").header("X-User-Passport", passportHeader(1L, "USER"))) .andExpect(status().isForbidden()); } - - private String bearerToken() { - return String.format("Bearer %s", VALID_JWT); - } } @Nested @DisplayName("2-2. 인증이 필요한 엔드포인트 - 관리자") class AdminEndPoint { - private final String VALID_JWT = JwtTestUtil.createToken(1L, Role.ROLE_ADMIN); - - @BeforeEach - void setAccessToken() { - given(tokenProvider.createAccessToken(any(), any())).willReturn(VALID_JWT); - - given(tokenResolver.getUserDataByAccessToken(VALID_JWT)).willReturn(1L); - - given(tokenResolver.getExpiredDateByAccessToken(VALID_JWT)) - .willReturn(Date.from(Instant.now()).getTime()); - given(tokenResolver.getRoles(VALID_JWT)).willReturn(List.of(Role.ROLE_ADMIN.name())); - } @Test @DisplayName("[관리자] 관리자 권한은 관리자 API에 접근 가능_1") - void 관리자_토큰으로_관리자_API_접근시_200응답_1() throws Exception { + void 관리자_패스포트로_관리자_API_접근시_200응답() throws Exception { mockMvc - .perform(get("/api/admin/test").header(HttpHeaders.AUTHORIZATION, bearerToken())) + .perform(get("/api/admin/test").header("X-User-Passport", passportHeader(1L, "ADMIN"))) .andExpect(status().isOk()); } - - private String bearerToken() { - return String.format("Bearer %s", VALID_JWT); - } } @Nested @DisplayName("3. 존재하지 않는 엔드포인트(UnknownEndpointFilter)") class UnknownEndpoint { + @Test void nonExistentShouldReturn404() throws Exception { mockMvc.perform(get("/api/does-not-exist")).andExpect(status().isNotFound()); diff --git a/eeos/src/test/java/com/blackcompany/eeos/config/security/AccessTokenFilterTest.java b/eeos/src/test/java/com/blackcompany/eeos/config/security/AccessTokenFilterTest.java deleted file mode 100644 index b58cabf99..000000000 --- a/eeos/src/test/java/com/blackcompany/eeos/config/security/AccessTokenFilterTest.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.blackcompany.eeos.config.security; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import com.blackcompany.eeos.auth.application.domain.token.TokenResolver; -import com.blackcompany.eeos.auth.application.exception.NotFoundHeaderTokenException; -import com.blackcompany.eeos.auth.presentation.support.AuthConstants; -import com.blackcompany.eeos.auth.presentation.support.TokenExtractor; -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.List; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.context.SecurityContextHolder; - -@ExtendWith(MockitoExtension.class) -class AccessTokenFilterTest { - - @Mock private TokenExtractor headerExtractor; - @Mock private TokenResolver tokenResolver; - @Mock private HttpServletRequest request; - @Mock private HttpServletResponse response; - @Mock private FilterChain filterChain; - - private AccessTokenFilter filter; - - @BeforeEach - void setUp() { - SecurityContextHolder.clearContext(); - filter = new AccessTokenFilter(headerExtractor, tokenResolver); - } - - @Test - @DisplayName("Authorization 헤더에 토큰이 있으면 헤더에서 추출한다.") - void extract_from_header_when_present() throws Exception { - // given - String token = "valid-access-token"; - when(headerExtractor.extract(request)).thenReturn(token); - when(tokenResolver.getUserDataByAccessToken(token)).thenReturn(1L); - when(tokenResolver.getRoles(token)).thenReturn(List.of("ROLE_USER")); - - // when - filter.doFilterInternal(request, response, filterChain); - - // then - assertNotNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("헤더에 토큰이 없고 쿠키에 AT가 있으면 쿠키에서 추출한다.") - void fallback_to_cookie_when_header_absent() throws Exception { - // given - String token = "cookie-access-token"; - when(headerExtractor.extract(request)).thenThrow(new NotFoundHeaderTokenException()); - when(request.getCookies()) - .thenReturn(new Cookie[] {new Cookie(AuthConstants.ACCESS_TOKEN_KEY, token)}); - when(tokenResolver.getUserDataByAccessToken(token)).thenReturn(2L); - when(tokenResolver.getRoles(token)).thenReturn(List.of("ROLE_USER")); - - // when - filter.doFilterInternal(request, response, filterChain); - - // then - assertNotNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain).doFilter(request, response); - } - - @Test - @DisplayName("헤더도 쿠키도 없으면 SecurityContext를 비우고 필터 체인을 계속한다.") - void clear_context_when_no_token() throws Exception { - // given - when(headerExtractor.extract(request)).thenThrow(new NotFoundHeaderTokenException()); - when(request.getCookies()).thenReturn(null); - - // when - filter.doFilterInternal(request, response, filterChain); - - // then - assertNull(SecurityContextHolder.getContext().getAuthentication()); - verify(filterChain).doFilter(request, response); - } -} diff --git a/eeos/src/test/java/com/blackcompany/eeos/config/security/PassportAuthenticationFilterTest.java b/eeos/src/test/java/com/blackcompany/eeos/config/security/PassportAuthenticationFilterTest.java new file mode 100644 index 000000000..2a57786d1 --- /dev/null +++ b/eeos/src/test/java/com/blackcompany/eeos/config/security/PassportAuthenticationFilterTest.java @@ -0,0 +1,222 @@ +package com.blackcompany.eeos.config.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * PassportAuthenticationFilter 단위 테스트 + * + *

Gateway가 주입하는 X-User-Passport 헤더를 파싱하여 SecurityContext를 올바르게 설정하는지 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class PassportAuthenticationFilterTest { + + @Mock private HttpServletRequest request; + @Mock private HttpServletResponse response; + @Mock private FilterChain filterChain; + + private PassportAuthenticationFilter filter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + filter = new PassportAuthenticationFilter(); + } + + // ────────────────────────────────────────────────────────── + // 정상 케이스 + // ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("유효한 X-User-Passport 헤더") + class ValidPassportHeader { + + @Test + @DisplayName("memberId와 roles가 담긴 Passport → JwtAuthentication 설정") + void valid_passport_sets_jwt_authentication() throws Exception { + String passport = passport(42L, "[\"USER\"]"); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(passport); + + filter.doFilterInternal(request, response, filterChain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth).isInstanceOf(JwtAuthentication.class); + assertThat(auth.getPrincipal()).isEqualTo(42L); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("memberId가 문자열로 담겨있어도 Long으로 파싱") + void memberId_as_string_is_parsed_to_long() throws Exception { + String passport = passportRaw("{\"memberId\":\"99\",\"roles\":[\"USER\"]}"); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(passport); + + filter.doFilterInternal(request, response, filterChain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth.getPrincipal()).isEqualTo(99L); + } + + @Test + @DisplayName("roles가 여러 개여도 모두 authority로 등록") + void multiple_roles_all_registered_as_authorities() throws Exception { + String passport = passport(1L, "[\"USER\",\"ADMIN\"]"); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(passport); + + filter.doFilterInternal(request, response, filterChain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth.getAuthorities()) + .extracting("authority") + .containsExactlyInAnyOrder("ROLE_USER", "ROLE_ADMIN"); + } + + @Test + @DisplayName("roles 필드 없어도 memberId 있으면 빈 권한으로 인증") + void passport_without_roles_still_authenticates() throws Exception { + String passport = passportRaw("{\"memberId\":7}"); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(passport); + + filter.doFilterInternal(request, response, filterChain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertThat(auth).isNotNull(); + assertThat(auth.getPrincipal()).isEqualTo(7L); + assertThat(auth.getAuthorities()).isEmpty(); + } + } + + // ────────────────────────────────────────────────────────── + // 헤더 없음 케이스 + // ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("X-User-Passport 헤더 없음") + class MissingPassportHeader { + + @Test + @DisplayName("헤더 없으면 SecurityContext 미설정, 체인 계속") + void no_header_skips_authentication() throws Exception { + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(null); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("헤더가 빈 문자열이어도 SecurityContext 미설정") + void blank_header_skips_authentication() throws Exception { + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(" "); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + } + + // ────────────────────────────────────────────────────────── + // 파싱 실패 케이스 — 예외를 던지지 않고 체인 계속 + // ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("유효하지 않은 Passport 값") + class InvalidPassportHeader { + + @Test + @DisplayName("Base64 디코딩 불가 값 → 인증 미설정, 필터 체인 계속 (예외 없음)") + void invalid_base64_does_not_throw() throws Exception { + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)) + .thenReturn("!!!not-base64!!!"); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("JSON 형식 아님 → 인증 미설정, 필터 체인 계속 (예외 없음)") + void invalid_json_does_not_throw() throws Exception { + String garbage = + Base64.getEncoder().encodeToString("not-json".getBytes(StandardCharsets.UTF_8)); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(garbage); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("memberId 없는 Passport → 인증 미설정, 필터 체인 계속") + void passport_without_memberId_skips_authentication() throws Exception { + String passport = passportRaw("{\"loginId\":\"user\",\"roles\":[\"USER\"]}"); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(passport); + + filter.doFilterInternal(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(filterChain).doFilter(request, response); + } + } + + // ────────────────────────────────────────────────────────── + // AccessTokenFilter 연동 — 이미 인증된 경우 스킵 + // ────────────────────────────────────────────────────────── + + @Nested + @DisplayName("PassportFilter → AccessTokenFilter 연동") + class PassportAccessTokenIntegration { + + @Test + @DisplayName("PassportFilter가 JwtAuthentication 설정 후 AccessTokenFilter는 해당 인증을 유지") + void access_token_filter_skips_when_passport_already_authenticated() throws Exception { + // 1. PassportFilter가 인증 설정 + String passport = passport(10L, "[\"USER\"]"); + when(request.getHeader(PassportAuthenticationFilter.PASSPORT_HEADER)).thenReturn(passport); + filter.doFilterInternal(request, response, filterChain); + + // SecurityContext에 JwtAuthentication이 세팅됐는지 확인 + Authentication beforeAccess = SecurityContextHolder.getContext().getAuthentication(); + assertThat(beforeAccess).isInstanceOf(JwtAuthentication.class); + assertThat(beforeAccess.getPrincipal()).isEqualTo(10L); + + // 2. AccessTokenFilter는 JwtAuthentication 감지 → 스킵 + // (AccessTokenFilter의 instanceof JwtAuthentication 분기가 동작함을 간접 검증) + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isInstanceOf(JwtAuthentication.class); + } + } + + // ────────────────────────────────────────────────────────── + // 헬퍼 + // ────────────────────────────────────────────────────────── + + /** memberId + roles JSON을 Base64 인코딩하여 Passport 헤더 값 생성 */ + private String passport(Long memberId, String rolesJson) { + String json = String.format("{\"memberId\":%d,\"roles\":%s}", memberId, rolesJson); + return passportRaw(json); + } + + private String passportRaw(String json) { + return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } +}