Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/main/java/com/recyclestudy/common/annotation/AuthDevice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.recyclestudy.common.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthDevice {
}
20 changes: 20 additions & 0 deletions src/main/java/com/recyclestudy/common/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.recyclestudy.common.config;

import com.recyclestudy.common.resolver.DeviceAuthArgumentResolver;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {

private final DeviceAuthArgumentResolver deviceAuthArgumentResolver;

@Override
public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(deviceAuthArgumentResolver);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.recyclestudy.common.resolver;

import com.recyclestudy.common.annotation.AuthDevice;
import com.recyclestudy.exception.UnauthorizedException;
import com.recyclestudy.member.domain.Device;
import com.recyclestudy.member.domain.DeviceIdentifier;
import com.recyclestudy.member.repository.DeviceRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
@RequiredArgsConstructor
public class DeviceAuthArgumentResolver implements HandlerMethodArgumentResolver {

private final DeviceRepository deviceRepository;

@Override
public boolean supportsParameter(final MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthDevice.class);
}

@Override
public Object resolveArgument(
final MethodParameter parameter,
final ModelAndViewContainer mavContainer,
final NativeWebRequest webRequest,
final WebDataBinderFactory binderFactory
) {
final String headerIdentifier = Optional.ofNullable(webRequest.getHeader("X-device-Id"))
.orElseThrow(() -> new UnauthorizedException("디바이스 인증 헤더가 누락되었습니다"));

final DeviceIdentifier identifier = DeviceIdentifier.from(headerIdentifier);
final Device device = deviceRepository.findByIdentifier(identifier)
.orElseThrow(() -> new UnauthorizedException("유효하지 않은 디바이스입니다"));

if (!device.isActive()) {
throw new UnauthorizedException("인증되지 않은 디바이스입니다");
}

return identifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
Expand All @@ -33,10 +34,22 @@ public String authenticateDevice(

@DeleteMapping
@ResponseBody
public ResponseEntity<Void> deleteDevice(@RequestBody final DeviceDeleteRequest request) {
final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), request.deviceIdentifier(),
public ResponseEntity<Void> deleteDevice(
@RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier,
@RequestBody final DeviceDeleteRequest request
) {
final String resolvedIdentifier = getResolvedIdentifier(request.deviceIdentifier(), headerIdentifier);

final DeviceDeleteInput input = DeviceDeleteInput.from(request.email(), resolvedIdentifier,
request.targetDeviceIdentifier());
memberService.deleteDevice(input);
return ResponseEntity.noContent().build();
}

private String getResolvedIdentifier(final String identifier, final String headerIdentifier) {
if (headerIdentifier == null) {
return identifier;
}
return headerIdentifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -41,11 +42,21 @@ public ResponseEntity<MemberSaveResponse> saveMember(@RequestBody final MemberSa
@GetMapping
public ResponseEntity<MemberFindResponse> findAllMemberDevices(
@RequestParam(name = "email") final String email,
@RequestParam(name = "identifier") final String identifier
@RequestParam(name = "identifier", required = false) final String identifier,
@RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier
) {
final MemberFindInput input = MemberFindInput.from(email, identifier);
final String resolvedIdentifier = getResolvedIdentifier(identifier, headerIdentifier);

final MemberFindInput input = MemberFindInput.from(email, resolvedIdentifier);
final MemberFindOutput output = memberService.findAllMemberDevices(input);
final MemberFindResponse response = MemberFindResponse.from(output);
return ResponseEntity.ok(response);
}

private String getResolvedIdentifier(final String identifier, final String headerIdentifier) {
if (headerIdentifier == null) {
return identifier;
}
return headerIdentifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

Expand All @@ -21,10 +22,22 @@ public class ReviewController {
private final ReviewService reviewService;

@PostMapping
public ResponseEntity<ReviewSaveResponse> saveReview(@RequestBody ReviewSaveRequest request) {
final ReviewSaveInput input = request.toInput();
public ResponseEntity<ReviewSaveResponse> saveReview(
@RequestHeader(value = "X-Device-Id", required = false) String headerIdentifier,
@RequestBody ReviewSaveRequest request
) {
final String resolvedIdentifier = getResolvedIdentifier(request.identifier(), headerIdentifier);

final ReviewSaveInput input = ReviewSaveInput.of(resolvedIdentifier, request.targetUrl());
final ReviewSaveOutput output = reviewService.saveReview(input);
ReviewSaveResponse response = ReviewSaveResponse.of(output.url(), output.scheduledAts());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

private String getResolvedIdentifier(final String identifier, final String headerIdentifier) {
if (headerIdentifier == null) {
return identifier;
}
return headerIdentifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -545,4 +545,41 @@ void deleteDevice_NullTargetIdentifier() {
.statusCode(HttpStatus.BAD_REQUEST.value())
.body("message", equalTo("null이 될 수 없습니다: value"));
}

@Test
@DisplayName("헤더로 디바이스 인증하여 삭제 시 204 응답을 반환한다")
void deleteDevice_WithHeader() {
// given
final String headerIdentifier = "device-id";
final DeviceDeleteRequest request = new DeviceDeleteRequest("test@test.com", null, "target-id");

doNothing().when(memberService).deleteDevice(any());

// when
// then
given(this.spec)
.filter(document(DEFAULT_REST_DOC_PATH,
builder()
.tag("Device")
.summary("디바이스 삭제")
.description("헤더로 디바이스 인증하여 삭제 시 204 응답을 반환한다")
.requestHeaders(
headerWithName("X-Device-Id").description("디바이스 식별자")
)
.requestFields(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("identifier").type(JsonFieldType.STRING)
.description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional(),
fieldWithPath("targetIdentifier").type(JsonFieldType.STRING)
.description("삭제할 디바이스 식별자")
)
))
.contentType(MediaType.APPLICATION_JSON_VALUE)
.header("X-Device-Id", headerIdentifier)
.body(request)
.when()
.delete("/api/v1/device")
.then()
.statusCode(HttpStatus.NO_CONTENT.value());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.queryParameters;
Expand Down Expand Up @@ -410,4 +411,63 @@ void findAllMemberDevices_NullIdentifier() {
.then()
.statusCode(HttpStatus.BAD_REQUEST.value());
}

@Test
@DisplayName("헤더로 디바이스 인증하여 멤버의 모든 디바이스 정보를 조회한다")
void findAllMemberDevices_WithHeader() {
// given
final String email = "test@test.com";
final String headerIdentifier = "device-id-1";

final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from(headerIdentifier),
LocalDateTime.now().minusDays(1)
);
final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from("device-id-2"),
LocalDateTime.now()
);

final MemberFindOutput output = new MemberFindOutput(
Email.from(email),
List.of(device1, device2)
);

given(memberService.findAllMemberDevices(any())).willReturn(output);

// when
// then
given(this.spec)
.filter(document(DEFAULT_REST_DOC_PATH,
builder()
.tag("Member")
.summary("멤버 디바이스 조회")
.description("헤더로 디바이스 인증하여 멤버의 모든 디바이스 정보를 조회한다")
.requestHeaders(
headerWithName("X-Device-Id").description("디바이스 식별자")
)
.queryParameters(
parameterWithName("email").description("이메일"),
parameterWithName("identifier").description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional()
)
.responseFields(
fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"),
fieldWithPath("devices").type(JsonFieldType.ARRAY).description("디바이스 목록"),
fieldWithPath("devices[].identifier").type(JsonFieldType.STRING)
.description("디바이스 식별자 값"),
fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING)
.description("디바이스 생성일")
),
queryParameters(
parameterWithName("email").description("이메일")
)
))
.header("X-Device-Id", headerIdentifier)
.param("email", email)
.when()
.get("/api/v1/members")
.then()
.statusCode(HttpStatus.OK.value())
.body("devices", hasSize(2));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import static org.hamcrest.Matchers.equalTo;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;

class ReviewControllerTest extends APIBaseTest {
Expand Down Expand Up @@ -139,4 +140,48 @@ void saveReview_InactiveDevice() {
.statusCode(HttpStatus.UNAUTHORIZED.value())
.body("message", equalTo("인증되지 않은 디바이스입니다"));
}

@Test
@DisplayName("헤더로 디바이스 인증하여 리뷰를 저장하면 201 응답을 반환한다")
void saveReview_WithHeader() {
// given
final String identifier = "device-id";
final String url = "https://test.com";
final ReviewSaveRequest request = new ReviewSaveRequest(null, url);
final ReviewSaveOutput output = ReviewSaveOutput.of(ReviewURL.from(url), List.of(LocalDateTime.now()));

given(reviewService.saveReview(any())).willReturn(output);

// when
// then
given(this.spec)
.filter(document(DEFAULT_REST_DOC_PATH,
builder()
.tag("Review")
.summary("리뷰 저장")
.description("헤더로 디바이스 인증하여 리뷰를 저장하면 201 응답을 반환한다")
.requestHeaders(
headerWithName("X-Device-Id").description("디바이스 식별자")
)
.requestFields(
fieldWithPath("identifier").type(JsonFieldType.STRING)
.description("디바이스 식별자 (deprecated, 헤더 사용 권장)").optional(),
fieldWithPath("url").type(JsonFieldType.STRING)
.description("리뷰할 URL")
)
.responseFields(
fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"),
fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY)
.description("복습 예정 일시 목록")
)
))
.contentType(MediaType.APPLICATION_JSON_VALUE)
.header("X-Device-Id", identifier)
.body(request)
.when()
.post("/api/v1/reviews")
.then()
.statusCode(HttpStatus.CREATED.value())
.body("url", equalTo(url));
}
}
1 change: 1 addition & 0 deletions src/test/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ spring:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
open-in-view: false

mail:
host: localhost
Expand Down