Skip to content

Commit cec8bda

Browse files
authored
Merge pull request #232 from sb10-part4-team5/feature/3-admin-user-list
feat: 사용자 목록 조회 구현
2 parents 8d30ab4 + 7e33cc1 commit cec8bda

20 files changed

Lines changed: 943 additions & 26 deletions

File tree

src/main/java/com/codeit/team5/mopl/auth/controller/AuthController.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public ResponseEntity<Void> logout(
6060
.build();
6161
}
6262

63+
@Override
6364
@PostMapping("/refresh")
6465
public ResponseEntity<JwtResponse> refresh(
6566
@CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.codeit.team5.mopl.auth.handler;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import java.util.function.Supplier;
6+
import org.springframework.security.web.csrf.CsrfToken;
7+
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
8+
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
9+
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
10+
import org.springframework.util.StringUtils;
11+
12+
public final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {
13+
14+
private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
15+
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();
16+
17+
@Override
18+
public void handle(
19+
HttpServletRequest request,
20+
HttpServletResponse response,
21+
Supplier<CsrfToken> csrfToken
22+
) {
23+
// 응답 렌더링 시 BREACH 방어
24+
this.xor.handle(request, response, csrfToken);
25+
// 지연 토큰 강제 로드 -> 쿠키에 기록
26+
csrfToken.get();
27+
}
28+
29+
@Override
30+
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
31+
String headerValue = request.getHeader(csrfToken.getHeaderName());
32+
33+
// 헤더(X-XSRF-TOKEN)로 온 raw 토큰은 plain, 그 외는 xor로 해석
34+
return StringUtils.hasText(headerValue)
35+
? this.plain.resolveCsrfTokenValue(request, csrfToken)
36+
: this.xor.resolveCsrfTokenValue(request, csrfToken);
37+
}
38+
}

src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.codeit.team5.mopl.config;
22

33
import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter;
4+
import com.codeit.team5.mopl.auth.handler.SpaCsrfTokenRequestHandler;
45
import com.codeit.team5.mopl.auth.handler.UserAccessDeniedHandler;
56
import com.codeit.team5.mopl.auth.handler.UserAuthenticationEntryPoint;
67
import com.codeit.team5.mopl.auth.security.provider.MoplAuthenticationProvider;
@@ -16,7 +17,6 @@
1617
import org.springframework.security.web.SecurityFilterChain;
1718
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
1819
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
19-
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
2020
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
2121

2222
@Configuration
@@ -34,13 +34,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
3434
PathPatternRequestMatcher.Builder paths =
3535
PathPatternRequestMatcher.withDefaults();
3636

37-
CsrfTokenRequestAttributeHandler csrfTokenRequestHandler =
38-
new CsrfTokenRequestAttributeHandler();
39-
4037
return http
4138
.csrf(csrf -> csrf
4239
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
43-
.csrfTokenRequestHandler(csrfTokenRequestHandler)
40+
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
4441
.ignoringRequestMatchers(
4542
paths.matcher(HttpMethod.POST, "/api/auth/refresh")
4643
)
@@ -55,18 +52,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
5552
)
5653
.authorizeHttpRequests(auth -> auth
5754
.requestMatchers("/api/auth/sign-out").permitAll()
55+
56+
.requestMatchers(HttpMethod.POST, "/api/users").permitAll()
57+
.requestMatchers(HttpMethod.GET, "/api/users").hasRole("ADMIN")
58+
.requestMatchers(HttpMethod.PATCH, "/api/users/*/role").hasRole("ADMIN")
59+
.requestMatchers(HttpMethod.PATCH, "/api/users/*/locked").hasRole("ADMIN")
60+
.requestMatchers(HttpMethod.GET, "/api/users/*").authenticated()
61+
.requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated()
62+
63+
.requestMatchers("/api/users/**").authenticated()
5864
.requestMatchers("/api/follows/**").authenticated()
5965
.requestMatchers("/api/notifications/**").authenticated()
60-
.requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated()
61-
.requestMatchers(HttpMethod.GET, "/api/users/*").authenticated()
62-
.requestMatchers("/api/users").permitAll()
66+
6367
.requestMatchers("/api/auth/sign-in").permitAll()
6468
.requestMatchers("/api/auth/csrf-token").permitAll()
6569
.requestMatchers("/api/auth/refresh").permitAll()
66-
.requestMatchers(HttpMethod.PATCH, "/api/users/*/role").hasRole("ADMIN")
67-
.requestMatchers(HttpMethod.PATCH, "/api/users/*/locked").hasRole("ADMIN")
68-
69-
// Swagger, actuator 필요하면 추가
7070
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
7171

7272
.anyRequest().permitAll()

src/main/java/com/codeit/team5/mopl/config/WebConfig.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.codeit.team5.mopl.config;
22

33
import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException;
4-
import com.codeit.team5.mopl.watcher.constant.SortByType;
4+
import com.codeit.team5.mopl.user.constant.UserSortBy;
55
import org.springframework.context.annotation.Configuration;
66
import org.springframework.data.domain.Sort;
77
import org.springframework.format.FormatterRegistry;
@@ -21,5 +21,7 @@ public void addFormatters(FormatterRegistry registry) {
2121
}
2222
throw new InvalidSortDirectionException(value);
2323
});
24+
25+
registry.addConverter(String.class, UserSortBy.class, UserSortBy::from);
2426
}
2527
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.codeit.team5.mopl.user.constant;
2+
3+
import com.codeit.team5.mopl.user.exception.InvalidUserSortByException;
4+
import java.util.Arrays;
5+
import lombok.Getter;
6+
import lombok.RequiredArgsConstructor;
7+
8+
@Getter
9+
@RequiredArgsConstructor
10+
public enum UserSortBy {
11+
NAME("name"),
12+
EMAIL("email"),
13+
CREATED_AT("createdAt"),
14+
LOCKED("isLocked"),
15+
ROLE("role")
16+
;
17+
18+
private final String value;
19+
20+
public static UserSortBy from(String value) {
21+
return Arrays.stream(values())
22+
.filter(sortBy -> sortBy.value.equalsIgnoreCase(value))
23+
.findFirst()
24+
.orElseThrow(() -> new InvalidUserSortByException(value));
25+
}
26+
}

src/main/java/com/codeit/team5/mopl/user/controller/UserController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import com.codeit.team5.mopl.auth.security.details.MoplUserDetails;
44
import com.codeit.team5.mopl.binarycontent.support.MultipartFiles;
5+
import com.codeit.team5.mopl.global.dto.CursorResponse;
56
import com.codeit.team5.mopl.user.controller.api.UserApi;
7+
import com.codeit.team5.mopl.user.dto.request.UserCursorRequest;
68
import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest;
79
import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest;
810
import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest;
@@ -93,4 +95,15 @@ public ResponseEntity<Void> updateLockStatus(
9395

9496
return ResponseEntity.noContent().build();
9597
}
98+
99+
@Override
100+
@GetMapping
101+
public ResponseEntity<CursorResponse<UserResponse>> getUsers(
102+
@Valid UserCursorRequest request) {
103+
log.info("User list request: GET /api/users");
104+
105+
CursorResponse<UserResponse> response = userService.findUsers(request);
106+
107+
return ResponseEntity.ok(response);
108+
}
96109
}

src/main/java/com/codeit/team5/mopl/user/controller/api/UserApi.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.codeit.team5.mopl.user.controller.api;
22

33
import com.codeit.team5.mopl.auth.security.details.MoplUserDetails;
4+
import com.codeit.team5.mopl.global.dto.CursorResponse;
45
import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion;
6+
import com.codeit.team5.mopl.user.dto.request.UserCursorRequest;
57
import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest;
68
import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest;
79
import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest;
@@ -140,4 +142,24 @@ ResponseEntity<Void> updateLockStatus(
140142
@Parameter(description = "변경할 사용자 잠금 상태", required = true)
141143
@Valid @RequestBody UserLockedUpdateRequest request
142144
);
145+
146+
@Operation(
147+
summary = "[어드민] 사용자 목록 조회",
148+
description = "[어드민 기능] 사용자의 목록을 조회합니다."
149+
)
150+
@ApiResponses({
151+
@ApiResponse(responseCode = "200", description = "목록 조회 성공",
152+
content = @Content(schema = @Schema(implementation = CursorResponse.class))),
153+
@ApiResponse(responseCode = "400", description = "잘못된 요청",
154+
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
155+
@ApiResponse(responseCode = "401", description = "인증 오류",
156+
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
157+
@ApiResponse(responseCode = "403", description = "권한 오류(관리자만 조회 가능)",
158+
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
159+
@ApiResponse(responseCode = "500", description = "서버 내부 오류",
160+
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
161+
})
162+
ResponseEntity<CursorResponse<UserResponse>> getUsers(
163+
@Parameter(hidden = true)UserCursorRequest request
164+
);
143165
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.codeit.team5.mopl.user.dto.request;
2+
3+
import com.codeit.team5.mopl.user.constant.UserSortBy;
4+
import com.codeit.team5.mopl.user.entity.UserRole;
5+
import jakarta.validation.constraints.Max;
6+
import jakarta.validation.constraints.NotNull;
7+
import jakarta.validation.constraints.Positive;
8+
import java.util.UUID;
9+
import org.springframework.data.domain.Sort;
10+
11+
public record UserCursorRequest(
12+
String emailLike,
13+
UserRole roleEqual,
14+
Boolean isLocked,
15+
String cursor,
16+
UUID idAfter,
17+
18+
@NotNull
19+
@Positive
20+
@Max(100)
21+
Integer limit,
22+
23+
@NotNull
24+
Sort.Direction sortDirection,
25+
26+
@NotNull
27+
UserSortBy sortBy
28+
) {
29+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.codeit.team5.mopl.user.exception;
2+
3+
import com.codeit.team5.mopl.global.exception.BusinessException;
4+
import org.springframework.http.HttpStatus;
5+
6+
public class InvalidUserSortByException extends BusinessException {
7+
8+
public InvalidUserSortByException(String value) {
9+
super(HttpStatus.BAD_REQUEST, "정렬 입력값이 올바르지 않습니다. value=" + value );
10+
}
11+
}

src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package com.codeit.team5.mopl.user.mapper;
22

3+
import com.codeit.team5.mopl.global.dto.CursorResponse;
4+
import com.codeit.team5.mopl.user.constant.UserSortBy;
35
import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest;
46
import com.codeit.team5.mopl.user.dto.response.UserResponse;
57
import com.codeit.team5.mopl.user.entity.User;
8+
import java.util.List;
69
import org.mapstruct.Mapper;
710
import org.mapstruct.Mapping;
811
import org.mapstruct.ReportingPolicy;
12+
import org.springframework.data.domain.Sort.Direction;
913

1014
@Mapper(
1115
componentModel = "spring",
@@ -15,4 +19,29 @@ public interface UserMapper {
1519
@Mapping(target = "role", source = "user.role")
1620
@Mapping(target = "profileImageUrl", source = "user.profileImage.url")
1721
UserResponse toDto(User user);
22+
23+
default CursorResponse<UserResponse> toCursor(
24+
List<User> page, boolean hasNext, long totalCount, UserSortBy sortBy, Direction sortDirection
25+
) {
26+
String nextCursor = null;
27+
String nextIdAfter = null;
28+
29+
if (hasNext && !page.isEmpty()) {
30+
User last = page.get(page.size() - 1);
31+
nextCursor = switch (sortBy) {
32+
case EMAIL -> last.getEmail();
33+
case NAME -> last.getName();
34+
case CREATED_AT -> last.getCreatedAt().toString();
35+
case ROLE -> last.getRole().name();
36+
case LOCKED -> String.valueOf(last.isLocked());
37+
};
38+
39+
nextIdAfter = last.getId().toString();
40+
}
41+
42+
List<UserResponse> data = page.stream().map(this::toDto).toList();
43+
String direction = sortDirection == Direction.ASC ? "ASCENDING" : "DESCENDING";
44+
45+
return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, sortBy.getValue(), direction);
46+
}
1847
}

0 commit comments

Comments
 (0)