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
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public ResponseEntity<Void> logout(
.build();
}

@Override
@PostMapping("/refresh")
public ResponseEntity<JwtResponse> refresh(
@CookieValue(name = "REFRESH_TOKEN", required = false) String refreshToken
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.codeit.team5.mopl.auth.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.function.Supplier;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;

public final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {

private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
Supplier<CsrfToken> csrfToken
) {
// 응답 렌더링 시 BREACH 방어
this.xor.handle(request, response, csrfToken);
// 지연 토큰 강제 로드 -> 쿠키에 기록
csrfToken.get();
}

@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String headerValue = request.getHeader(csrfToken.getHeaderName());

// 헤더(X-XSRF-TOKEN)로 온 raw 토큰은 plain, 그 외는 xor로 해석
return StringUtils.hasText(headerValue)
? this.plain.resolveCsrfTokenValue(request, csrfToken)
: this.xor.resolveCsrfTokenValue(request, csrfToken);
}
}
24 changes: 12 additions & 12 deletions src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.auth.filter.JwtAuthenticationFilter;
import com.codeit.team5.mopl.auth.handler.SpaCsrfTokenRequestHandler;
import com.codeit.team5.mopl.auth.handler.UserAccessDeniedHandler;
import com.codeit.team5.mopl.auth.handler.UserAuthenticationEntryPoint;
import com.codeit.team5.mopl.auth.security.provider.MoplAuthenticationProvider;
Expand All @@ -16,7 +17,6 @@
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;

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

CsrfTokenRequestAttributeHandler csrfTokenRequestHandler =
new CsrfTokenRequestAttributeHandler();

return http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(csrfTokenRequestHandler)
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
.ignoringRequestMatchers(
paths.matcher(HttpMethod.POST, "/api/auth/refresh")
)
Expand All @@ -55,18 +52,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/sign-out").permitAll()

.requestMatchers(HttpMethod.POST, "/api/users").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/users/*/role").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/users/*/locked").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/users/*").authenticated()
.requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated()

.requestMatchers("/api/users/**").authenticated()
.requestMatchers("/api/follows/**").authenticated()
.requestMatchers("/api/notifications/**").authenticated()
.requestMatchers(HttpMethod.PATCH, "/api/users/*").authenticated()
.requestMatchers(HttpMethod.GET, "/api/users/*").authenticated()
.requestMatchers("/api/users").permitAll()

.requestMatchers("/api/auth/sign-in").permitAll()
.requestMatchers("/api/auth/csrf-token").permitAll()
.requestMatchers("/api/auth/refresh").permitAll()
.requestMatchers(HttpMethod.PATCH, "/api/users/*/role").hasRole("ADMIN")
.requestMatchers(HttpMethod.PATCH, "/api/users/*/locked").hasRole("ADMIN")

// Swagger, actuator 필요하면 추가
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()

.anyRequest().permitAll()
Comment thread
shong9124 marked this conversation as resolved.
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/codeit/team5/mopl/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.codeit.team5.mopl.config;

import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException;
import com.codeit.team5.mopl.watcher.constant.SortByType;
import com.codeit.team5.mopl.user.constant.UserSortBy;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.Sort;
import org.springframework.format.FormatterRegistry;
Expand All @@ -21,5 +21,7 @@ public void addFormatters(FormatterRegistry registry) {
}
throw new InvalidSortDirectionException(value);
});

registry.addConverter(String.class, UserSortBy.class, UserSortBy::from);
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/codeit/team5/mopl/user/constant/UserSortBy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.codeit.team5.mopl.user.constant;

import com.codeit.team5.mopl.user.exception.InvalidUserSortByException;
import java.util.Arrays;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum UserSortBy {
NAME("name"),
EMAIL("email"),
CREATED_AT("createdAt"),
LOCKED("isLocked"),
ROLE("role")
Comment thread
shong9124 marked this conversation as resolved.
;

private final String value;

public static UserSortBy from(String value) {
return Arrays.stream(values())
.filter(sortBy -> sortBy.value.equalsIgnoreCase(value))
.findFirst()
.orElseThrow(() -> new InvalidUserSortByException(value));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import com.codeit.team5.mopl.auth.security.details.MoplUserDetails;
import com.codeit.team5.mopl.binarycontent.support.MultipartFiles;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.user.controller.api.UserApi;
import com.codeit.team5.mopl.user.dto.request.UserCursorRequest;
import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest;
import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest;
import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest;
Expand Down Expand Up @@ -93,4 +95,15 @@ public ResponseEntity<Void> updateLockStatus(

return ResponseEntity.noContent().build();
}

@Override
@GetMapping
public ResponseEntity<CursorResponse<UserResponse>> getUsers(
Comment thread
shong9124 marked this conversation as resolved.
@Valid UserCursorRequest request) {
log.info("User list request: GET /api/users");

CursorResponse<UserResponse> response = userService.findUsers(request);

return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.codeit.team5.mopl.user.controller.api;

import com.codeit.team5.mopl.auth.security.details.MoplUserDetails;
import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion;
import com.codeit.team5.mopl.user.dto.request.UserCursorRequest;
import com.codeit.team5.mopl.user.dto.request.UserLockedUpdateRequest;
import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest;
import com.codeit.team5.mopl.user.dto.request.UserRoleUpdateRequest;
Expand Down Expand Up @@ -140,4 +142,24 @@ ResponseEntity<Void> updateLockStatus(
@Parameter(description = "변경할 사용자 잠금 상태", required = true)
@Valid @RequestBody UserLockedUpdateRequest request
);

@Operation(
summary = "[어드민] 사용자 목록 조회",
description = "[어드민 기능] 사용자의 목록을 조회합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "목록 조회 성공",
content = @Content(schema = @Schema(implementation = CursorResponse.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "401", description = "인증 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "403", description = "권한 오류(관리자만 조회 가능)",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))),
@ApiResponse(responseCode = "500", description = "서버 내부 오류",
content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class)))
})
ResponseEntity<CursorResponse<UserResponse>> getUsers(
@Parameter(hidden = true)UserCursorRequest request
);
Comment thread
shong9124 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.codeit.team5.mopl.user.dto.request;

import com.codeit.team5.mopl.user.constant.UserSortBy;
import com.codeit.team5.mopl.user.entity.UserRole;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.util.UUID;
import org.springframework.data.domain.Sort;

public record UserCursorRequest(
String emailLike,
UserRole roleEqual,
Boolean isLocked,
String cursor,
UUID idAfter,

@NotNull
@Positive
@Max(100)
Integer limit,
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@NotNull
Sort.Direction sortDirection,

@NotNull
UserSortBy sortBy
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.codeit.team5.mopl.user.exception;

import com.codeit.team5.mopl.global.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class InvalidUserSortByException extends BusinessException {

public InvalidUserSortByException(String value) {
super(HttpStatus.BAD_REQUEST, "정렬 입력값이 올바르지 않습니다. value=" + value );
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/codeit/team5/mopl/user/mapper/UserMapper.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.codeit.team5.mopl.user.mapper;

import com.codeit.team5.mopl.global.dto.CursorResponse;
import com.codeit.team5.mopl.user.constant.UserSortBy;
import com.codeit.team5.mopl.user.dto.request.UserRegisterRequest;
import com.codeit.team5.mopl.user.dto.response.UserResponse;
import com.codeit.team5.mopl.user.entity.User;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
import org.springframework.data.domain.Sort.Direction;

@Mapper(
componentModel = "spring",
Expand All @@ -15,4 +19,29 @@ public interface UserMapper {
@Mapping(target = "role", source = "user.role")
@Mapping(target = "profileImageUrl", source = "user.profileImage.url")
UserResponse toDto(User user);

default CursorResponse<UserResponse> toCursor(
List<User> page, boolean hasNext, long totalCount, UserSortBy sortBy, Direction sortDirection
) {
String nextCursor = null;
String nextIdAfter = null;

if (hasNext && !page.isEmpty()) {
User last = page.get(page.size() - 1);
nextCursor = switch (sortBy) {
case EMAIL -> last.getEmail();
case NAME -> last.getName();
case CREATED_AT -> last.getCreatedAt().toString();
case ROLE -> last.getRole().name();
case LOCKED -> String.valueOf(last.isLocked());
};

nextIdAfter = last.getId().toString();
}

List<UserResponse> data = page.stream().map(this::toDto).toList();
String direction = sortDirection == Direction.ASC ? "ASCENDING" : "DESCENDING";

return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, sortBy.getValue(), direction);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.codeit.team5.mopl.user.repository;

import com.codeit.team5.mopl.user.entity.User;
import com.codeit.team5.mopl.user.repository.querydsl.UserQueryRepository;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import java.util.UUID;
Expand All @@ -9,7 +10,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserRepository extends JpaRepository<User, UUID> {
public interface UserRepository extends JpaRepository<User, UUID>, UserQueryRepository {
boolean existsByEmail(String email);

Optional<User> findByEmail(String email);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.codeit.team5.mopl.user.repository.querydsl;

import com.codeit.team5.mopl.user.dto.request.UserCursorRequest;
import com.codeit.team5.mopl.user.entity.User;
import java.util.List;

public interface UserQueryRepository {

List<User> findUsers(UserCursorRequest request, int fetchLimit);

long countUsers(UserCursorRequest request);

}
Loading
Loading