Skip to content
Open
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
@@ -0,0 +1,52 @@
package com.nextcloudlab.kickytime.config;

import java.util.LinkedHashSet;
import java.util.Set;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

import com.nextcloudlab.kickytime.user.repository.UserRepository;

import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
public class JwtAuthConverterConfig {

private final UserRepository userRepository;

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();

converter.setJwtGrantedAuthoritiesConverter(
(Jwt jwt) -> {
Set<GrantedAuthority> authorities = new LinkedHashSet<>();

String cognitoSub = jwt.getClaimAsString("sub");
if (cognitoSub == null || cognitoSub.isBlank()) {
return authorities;
}

userRepository
.findByCognitoSub(cognitoSub)
.ifPresent(
user -> {
var role = user.getRole();
if (role != null) {
authorities.add(
new SimpleGrantedAuthority("ROLE_" + role));
}
});

return authorities;
});

return converter;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,27 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;

private final JwtAuthenticationConverter jwtAuthConverter;

public SecurityConfig(JwtAuthenticationConverter jwtAuthConverter) {
this.jwtAuthConverter = jwtAuthConverter;
}

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
Expand All @@ -34,7 +43,12 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
.anyRequest()
.permitAll())
.oauth2ResourceServer(
oauth2 -> oauth2.jwt(jwt -> jwt.decoder(accessTokenDecoder())))
oauth2 ->
oauth2.jwt(
jwt ->
jwt.decoder(accessTokenDecoder())
.jwtAuthenticationConverter(
jwtAuthConverter)))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable);
return http.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.util.Optional;
import java.util.stream.Collectors;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand All @@ -15,7 +16,6 @@
import com.nextcloudlab.kickytime.match.entity.MatchStatus;
import com.nextcloudlab.kickytime.match.repository.MatchParticipantRepository;
import com.nextcloudlab.kickytime.match.repository.MatchRepository;
import com.nextcloudlab.kickytime.user.entity.RoleEnum;
import com.nextcloudlab.kickytime.user.entity.User;
import com.nextcloudlab.kickytime.user.repository.UserRepository;

Expand Down Expand Up @@ -45,16 +45,13 @@ public List<MatchResponseDto> getAllMatches() {

// 경기 개설
@Transactional
@PreAuthorize("hasRole('ADMIN')")
public void createMatch(MatchCreateRequestDto requestDto) {
User user =
userRepository
.findById(requestDto.createdBy())
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));

if (user.getRole() != RoleEnum.ADMIN) {
throw new IllegalStateException("관리자 권한이 있는 사용자만 경기를 개설할 수 있습니다.");
}

Match match = new Match();
match.setMatchStatus(MatchStatus.OPEN);
match.setMatchDateTime(requestDto.matchDateTime());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@
import static org.springframework.http.HttpStatus.*;

import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.access.prepost.PreAuthorize;
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.server.ResponseStatusException;

import com.nextcloudlab.kickytime.user.entity.RoleEnum;
import com.nextcloudlab.kickytime.user.repository.UserRepository;
import com.nextcloudlab.kickytime.user.service.CognitoBackfillService;

import lombok.RequiredArgsConstructor;
Expand All @@ -22,31 +19,15 @@
@RequiredArgsConstructor
public class UserAdminController {
private final CognitoBackfillService backfillService;
private final UserRepository userRepository;

@PostMapping("/backfill-cognito")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<CognitoBackfillService.BackfillReport> backfillAll(
@AuthenticationPrincipal Jwt jwt,
@RequestParam(defaultValue = "false") boolean confirm) {
if (!confirm) {
throw new ResponseStatusException(BAD_REQUEST, "실행하려면 ?confirm=true 를 붙여주세요.");
}

String cognitoSub = (jwt != null) ? jwt.getClaimAsString("sub") : null;
if (cognitoSub == null || cognitoSub.isBlank()) {
throw new ResponseStatusException(UNAUTHORIZED, "유효한 인증 토큰이 필요합니다.");
}

boolean isAdmin =
userRepository
.findByCognitoSub(cognitoSub)
.map(u -> u.getRole() == RoleEnum.ADMIN)
.orElse(false);

if (!isAdmin) {
throw new ResponseStatusException(FORBIDDEN, "관리자만 실행할 수 있습니다.");
}

var report = backfillService.backfillAllUsers();
return ResponseEntity.ok(report);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,16 @@
import com.nextcloudlab.kickytime.user.service.UserService;
import com.nextcloudlab.kickytime.util.CognitoUserInfoClient;

import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;
private final CognitoUserInfoClient userInfoClient;

public UserController(UserService userService, CognitoUserInfoClient userInfoClient) {
this.userService = userService;
this.userInfoClient = userInfoClient;
}

@PostMapping("/signin-up")
public User signInUp(@AuthenticationPrincipal Jwt accessToken) {
String cognitoSub = accessToken.getClaimAsString("sub");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.nextcloudlab.kickytime.match.service;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.any;

import java.time.LocalDateTime;
import java.util.Optional;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Import;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

import com.nextcloudlab.kickytime.match.dto.MatchCreateRequestDto;
import com.nextcloudlab.kickytime.match.entity.Match;
import com.nextcloudlab.kickytime.match.repository.MatchParticipantRepository;
import com.nextcloudlab.kickytime.match.repository.MatchRepository;
import com.nextcloudlab.kickytime.user.entity.RoleEnum;
import com.nextcloudlab.kickytime.user.entity.User;
import com.nextcloudlab.kickytime.user.repository.UserRepository;

@SpringBootTest
@Import(MatchService.class) // 프록시가 적용된 실제 빈 주입
class MatchServiceSecurityTest {

@TestConfiguration
@EnableMethodSecurity // @PreAuthorize 활성화
static class MethodSecurityTestConfig {}

@Autowired MatchService matchService;

@MockitoBean UserRepository userRepository;
@MockitoBean MatchRepository matchRepository;
@MockitoBean MatchParticipantRepository participantRepository;

@Test
@DisplayName("USER 권한으로 createMatch 호출 시 접근 거부")
@WithMockUser(username = "user", roles = "USER")
void createMatchForbiddenWhenUserRoleIsUser() {
// given
var dto = new MatchCreateRequestDto(2L, LocalDateTime.now().plusDays(1), "서울", 10);

// userRepository가 호출되지 않더라도 상관 없지만, 혹시를 위해 준비
var creator = new User();
creator.setId(2L);
creator.setRole(RoleEnum.USER);
given(userRepository.findById(2L)).willReturn(Optional.of(creator));

// when & then
assertThatThrownBy(() -> matchService.createMatch(dto))
.isInstanceOf(AccessDeniedException.class);
}

@Test
@DisplayName("ADMIN 권한으로 createMatch 호출 시 통과")
@WithMockUser(username = "admin", roles = "ADMIN")
void createMatchAllowedWhenUserRoleIsAdmin() {
// given
var dto = new MatchCreateRequestDto(1L, LocalDateTime.now().plusDays(1), "서울", 10);

var admin = new User();
admin.setId(1L);
admin.setRole(RoleEnum.ADMIN);
given(userRepository.findById(1L)).willReturn(Optional.of(admin));
given(matchRepository.save(any(Match.class))).willReturn(new Match());

// when (예외 없어야 통과)
matchService.createMatch(dto);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -118,23 +118,6 @@ void createMatchUserNotFound() {
.hasMessage("사용자를 찾을 수 없습니다.");
}

@Test
@DisplayName("경기 개설 - 관리자 권한 없음 예외")
void createMatchNotAdminUser() {
// createRequestDto는 setter가 없으므로 새로운 인스턴스 생성
createRequestDto =
new MatchCreateRequestDto(
2L,
createRequestDto.matchDateTime(),
createRequestDto.location(),
createRequestDto.maxPlayers());
given(userRepository.findById(2L)).willReturn(Optional.of(regularUser));

assertThatThrownBy(() -> matchService.createMatch(createRequestDto))
.isInstanceOf(IllegalStateException.class)
.hasMessage("관리자 권한이 있는 사용자만 경기를 개설할 수 있습니다.");
}

@Test
@DisplayName("경기 참여 - 성공")
void joinMatchSuccess() {
Expand Down
Loading