티스토리 뷰

https://spring.academy/courses/building-a-rest-api-with-spring-boot/lessons/simple-spring-security-lab/lab

 

 

 

(이전글)

https://tose33.tistory.com/1331

 

Spring Security

https://spring.academy/courses/building-a-rest-api-with-spring-boot/lessons/simple-spring-security Simple Spring Security - Building a REST API with Spring Boot - Spring Academy Learn the differences between authentication and authorization, and how Spring

tose33.tistory.com

 

스프링 아카데미가 제공하는 Spring security 부분을 정리.

 

  • 목표
  • 인증, 인가를 받은 유저만이 cash card api 경로에 접근할수 있다
  • 올바른 사용자 (즉 해당 카드의 소유자) 만이 해당 카드에 접근할수 있다

 

1: Understand our Security Requirements

 

IF the user is authenticated // user 가 인증됐다면 

... AND they are authorized as a "card owner" // ...AND "card owner" 로서 인가 됐다면 

... ... AND they own the requested Cash Card // ... ... AND 요청이 온 신용카드를 소유한다면 

THEN complete the users's request // THEN user의 요청을 완료하라 

BUT don't allow users to access Cash Cards they do not own. // 하지만 user 가 소유하지 않는 신용카드에 대해서는 접근 불가능하게 하라 

 

위의 조건을 스프링 시큐리티가 제공하는 기능들을 이용해 만족시키는것이 목표다. 

 

2: Review update from Previous Lab

owner : 신용 카드를 만들고 관리가 가능한 사람 

 

테이블 (schema.sql)

CREATE TABLE cash_card
(
    ID       BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    AMOUNT   NUMBER NOT NULL DEFAULT 0,
    OWNER    VARCHAR(256) NOT NULL
);
 
데이터 (data.sql)
INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (99, 123.45, 'sarah1');
INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (100, 1.00, 'sarah1');
INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (101, 150.00, 'sarah1');
 
테이블은 ID,AMOUNT,ONWER 컬럼을 갖는다.
현재 데이터는 모두 owner='sarah1' 인 3개의 데이터가 존재한다.
 

3: Add the Spring Security Dependency

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // See https://docs.gradle.org/8.3/userguide/upgrading_version_8.html#test_framework_implementation_dependencies
	
    // 스프링 시큐리티 
    implementation 'org.springframework.boot:spring-boot-starter-security'

    implementation 'org.springframework.data:spring-data-jdbc'
    testImplementation 'com.h2database:h2'
}

 

 
 

4: Satisfy Spring Security's Dependencies

@Configuration
class SecurityConfig {

    @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
       return http.build();
   }

}

 

SecurityConfig.java 에서 스프링 시큐리티 관련 설정들을 해준다.

@Configuration 으로 해당 클래스를 설정 클래스로 만들고 SecurityFilterChain 을 반환하는 filterChain 을 @Bean 으로 빈으로 만들어 준다.

 

이전글에서 나와있듯 스프링 시큐리티는 Filter chain 을 기반으로 작동한다.

이렇게까지 해주면 아직 아무런 기능은 없지만 스프링 시큐리티의 기능들을 이용한 최저한의 세팅을 한 것이다.

 

5: Configure Basic Authentication (인증)

이제 스프링 시큐리티에서 인증을 어떻게하는지 알아보자.

이전글에서 아이디와 비밀번호를 credential 로서 사용하는 방식을 basic authentication 이라고 한다고 했다.

    @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http.authorizeHttpRequests(request -> request
            .requestMatchers("/cashcards/**")
            .authenticated())
            .csrf(csrf -> csrf.disable())
            .httpBasic(Customizer.withDefaults());

       return http.build();
   }

 

filter chain 을 basic authentication 을 사용하도록 설정해줬다.

 

"cashcards/" end point 로 오는 모든 요청은 basic authentication 을 사용해서 인증받아야 한다.

csrf(cross-site request forgery) 관련 보안은 disable 되있다.

 

6: Testing Basic Authentication

이제 filter chain 에서 설정한 보안이 작동되는지 테스트 해봐야 한다.

 

SecurityConfig.java

 @Bean
   PasswordEncoder passwordEncoder() {
       return new BCryptPasswordEncoder();
   }

   @Bean
   UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
        User.UserBuilder users = User.builder();
        UserDetails sarah = users
        .username("sarah1")
        .password(passwordEncoder.encode("abc123"))
        .roles() // No roles for now
        .build();
        return new InMemoryUserDetailsManager(sarah);
   }

 

testOnlyUsers() 에서 테스트에 사용할 임의의 유저를 만들었다.

username 은 'sarah1' 이고, password 는 'abc123' 이다.

 

다음은 테스트를 수행할 CashCardApplicationTests.java 테스트 클래스다.

   @Test
    void shouldReturnACashCardWhenDataIsSaved() {
    
        ResponseEntity<String> response = restTemplate
            .withBasicAuth("sarah1", "abc123") 
            .getForEntity("/cashcards/99", String.class);
            
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
		
        ... 
    }

 

restTemplate 을 이용해 "/cashcards/99" 경로에 GET 요청을 보낸다.

그런데 .withBasicAuth() 를 통해 아이디와 비밀번호를 같이 보낸다.

이렇게 해줄 경우 아이디와 비밀번호 즉 credential 은 http header 에 포함되어 전송되고 서버는 해당 credential 이 "/cashcards/99" 에 접근할 권한이 있는지 확인할것이다.

 

설정클래스에서 username="sarah1", password="abc123" 의 테스트 유저를 만들었기 때문에 테스트는 성공한다.

 

***

참고로 여기서는 restTemplate 을 쓰고있는데 restTemplate 는 deprecated 됐고 더이상 사용을 권장하지 않는다.

이제는 WebClient 사용을 권장한다.

https://tose33.tistory.com/1305

 

Spring WebClient

WebClient 는 스프링이 제공하는 말 그대로 client 로서의 역할을 하기 위한 모듈이다. 동기 API 요청을 처리할때 사용한다. RestTemplate vs WebClient https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/spri

tose33.tistory.com

***

 

테스트

다음 테스트는 잘못된 credential 이 요청왔을때 HttpStatus.UNAUTHROIZED 를 반환해야 한다는 테스트다.

   @Test
    void shouldNotReturnACashCardWhenUsingBadCredentials() {
    	// 잘못된 username
        ResponseEntity<String> response = restTemplate
        .withBasicAuth("BAD-USER", "abc123")
        .getForEntity("/cashcards/99", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);

	// 잘못된 password
        response = restTemplate
        .withBasicAuth("sarah1", "BAD-PASSWORD")
        .getForEntity("/cashcards/99", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
    }

 

 

 

7: Support Authorization (인가)

인증 설정을 했으니 이제 인가를 하는 방법이다.

 

스프링 시큐리티는 여러가지 방식의 인가를 지원하는데 가장 많이 사용하는 것은 Role-Based Access Controll (RBAC) 이다.

 

인증을 받은 여러 사용자로부터 서버에 요청이 올것인데, 오직 "card owner" 로서 인가를 받은 사용자만이 카드를 관리할수 있어야 한다.

 

위의 조건을 만족하도록 설정클래스의 filter chain 을 수정해보자.

    @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http
        .authorizeHttpRequests(request -> request
        .requestMatchers("/cashcards/**")
        .hasRole("CARD-OWNER")) // Role 이 "CARD-OWNER" 이어야 한다!
        .csrf(csrf -> csrf.disable())
        .httpBasic(Customizer.withDefaults());

        return http.build();
   }

 

"/cashcards/**" 경로로 오는 요청은 

"CARD-OWNER" 의 Role 을 갖고 있어야만 접근할수 있다

 

테스트

테스트를 위해 설정 클래스의 이전에 만들었던 테스트 유저 sarah 이외에 한명을 더 추가한다.

   @Bean
   UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
        // sarah 
        User.UserBuilder users = User.builder();
        UserDetails sarah = users
        .username("sarah1")
        .password(passwordEncoder.encode("abc123"))
        .roles("CARD-OWNER") // new role
        .build();

        // hankOwnsNoCards
        UserDetails hankOwnsNoCards = users
        .username("hank-owns-no-cards")
        .password(passwordEncoder.encode("qrs456"))
        .roles("NON-OWNER") // new role
        .build();
        return new InMemoryUserDetailsManager(sarah, hankOwnsNoCards);
   }

 

hankOwnsNoCards 라는 테스트 유저를 추가했다.

보면 sarah 는 "CARD-OWNDER" 라는 역할을 갖지만,

새로 추가된 hank 는 "NON-OWNER" 라는 역할을 갖는다.

 

테스트 코드는 다음과 같다.

   @Test
    void shouldRejectUsersWhoAreNotCardOwners() {
        ResponseEntity<String> response = restTemplate
        	.withBasicAuth("hank-owns-no-cards", "qrs456")
        	.getForEntity("/cashcards/99", String.class);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
    }

 

"/cashcards/99" 경로에 hank의 credential 로 접근하고 있다. (99는 sarah 의 카드다. 뭔가 이상하지만 우선 넘어가자)

하지만 우리는 설정 클래스에서 "cashcards/**" 의 경로는 오직 "CARD-OWNDER" 의 Role 을 갖는 사용자만이 접근할수 있다고 설정했으므로 HttpStatus.FORBIDDEN 을 리턴 받고 테스트는 성공한다.

 

여기까지 하면 RBAC Authorization 설정을 완료한 것이다.

 

그런데 지금 보안에 아주 큰 구멍이 있다.

현재 설정 클래스 설정은 "cashcards/**" 경로에는 "CARD-OWNER" 의 Role 을 갖는 사람들은 모두 접근할수 있다.

즉 "CARD-OWNER" 의 Role 을 갖고 있기만 하면 다른 사람의 카드에도 접근할수 있는 것이다.

 

예를들어 바로 위 테스트에서 hank 가 "/cashcards/99" 경로에 접근하다가 실패했는데 이는 hank 의 role 이 "NON-ONWER" 이기 때문이다. 

 

만약 hank 의 role 을 "CARD-OWNER" 로 바꾼다면 hank 는 sarah 가 소유하는 "/cashcards/99" 경로에 접근할수 있게된다.

 

 

8: Cash Card ownership: Repository Updates

이제 목표는 "CARD-OWNER" 의 Role 을 갖더라도 소유자가 아니라면 접근할수 없도록 하는 것이다.

 

CashCardRepository 인터페이스

interface CashCardRepository extends CrudRepository<CashCard, Long>, PagingAndSortingRepository<CashCard, Long> {
    CashCard findByIdAndOwner(Long id, String owner);
    Page<CashCard> findByOwner(String owner, PageRequest pageRequest);
}

 

CrudRepository, PagingAndSortingRepository 를 상속받고 있는데 이 둘은 모두 Spring Data 의 일부다.

이것들은 나도 제대로 다뤄본적은 없는데 이들의 내부에는 save, findById, findAll, delete 와 같은 CRUD 쿼리들이 작성되어 있다.

그래서 이들을 상속받고 메소드의 이름만 정의된 대로 만들어주면 따로 쿼리문을 작성하지 않아도 CRUD 를 수행할수 있다.

 

예를들어 findByFirstName(String firstName); 이런 식으로 이름이 정해져 있다.

 

9: Cash Card ownership: Controller Updates

이제 컨트롤러를 수정한다.

 

CashCardController.java

@RestController
@RequestMapping("/cashcards")
class CashCardController {
    
    ...
    
    @GetMapping("/{requestedId}")
    private ResponseEntity<CashCard> findById(@PathVariable Long requestedId) {
    	// findByIdAndOwner 
    	Optional<CashCard> cashCardOptional = 
            Optional.ofNullable(cashCardRepository.findByIdAndOwner(requestedId, principal.getName()));
    
        if (cashCardOptional.isPresent()) {
            return ResponseEntity.ok(cashCardOptional.get());
        } else {
            return ResponseEntity.notFound().build();
        }
    }
    
    ...
    
     @GetMapping
    private ResponseEntity<List<CashCard>> findAll(Pageable pageable, Principal principal) {
    	// findByOwner
        Page<CashCard> page = cashCardRepository.findByOwner(principal.getName(),
                PageRequest.of(
                        pageable.getPageNumber(),
                        pageable.getPageSize(),
                        pageable.getSortOr(Sort.by(Sort.Direction.ASC, "amount"))
                ));
        return ResponseEntity.ok(page.getContent());
    }
    
    ... 
}

 

기존의 findById() 메소드에서 를 findByIdAndOwner() 로 바꿔줬다.

 

Principal 은 스프링 시큐리티가 제공하고 컨트롤러에서 사용할수 있다 객체다.

  • Principal 이란 인증,인가를 받아야하는 리소스 사용자를 나타낸다
  • Principal 에 사용자의 authentication, authorization 정보가 담겨있다

 

principal.getName() 은 basic auth 가 제공하는 username 을 리턴한다.

 

테스트

data.sql

INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (99, 123.45, 'sarah1');
INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (100, 1.00, 'sarah1');
INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (101, 150.00, 'sarah1');

INSERT INTO CASH_CARD(ID, AMOUNT, OWNER) VALUES (102, 200.00, 'kumar2');
 
 
'kumar2' 가 소유하는 새로운 신용카드를 데이터에 추가했다.
 
 
  @Test
    void shouldNotAllowAccessToCashCardsTheyDoNotOwn() {
        ResponseEntity<String> response = restTemplate
            .withBasicAuth("sarah1", "abc123")
            .getForEntity("/cashcards/102", String.class); // kumar2's data
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }

 

"/cashcards/102" 는 kumar2 의 카드이므로 NOT_FOUND 를 리턴할것이다.

즉 테스트는 성공한다.

 

10: Cash Card ownership: Creation Updates

아직 보안의 허점이 하나 남아있다.

아래는 CashCardController 의 createCashCard() 메소드고, 새로운 신용카드를 생성하는 로직이다.

 @PostMapping
    private ResponseEntity<Void> createCashCard(@RequestBody CashCard newCashCardRequest, 
        UriComponentsBuilder ucb) {
        CashCard savedCashCard = cashCardRepository.save(newCashCardRequest);
        URI locationOfNewCashCard = ucb
                .path("cashcards/{id}")
                .buildAndExpand(savedCashCard.id())
                .toUri();
        return ResponseEntity.created(locationOfNewCashCard).build();
    }

 

여기서 문제는 newCashCardRequest 에 담겨있는 owner 를 기반으로 새로운 신용카드를 만들고 있다는 것이다.

즉 이대로라면 sarah 가 hank 의 카드를 만들수도 있게된다.

 

 

아래와 같이 인증, 인가가 완료된 Principal 을 사용해 카드를 만들어야 한다.

principal.getName() 으로 Principal 에 담겨있는 username 을 꺼내서 새로운 객체를 만들어서 저장하고 있다. 

    @PostMapping
    private ResponseEntity<Void> createCashCard(@RequestBody CashCard newCashCardRequest, 
        UriComponentsBuilder ucb, Principal principal) {
        // owner를 갖는 새로운 CashCard 를 만든다         
        CashCard cashCardWithOwner = 
            new CashCard(null, newCashCardRequest.amount(), principal.getName());
        // owner 를 갖는 CashCard 를 저장! 
        CashCard savedCashCard = cashCardRepository.save(cashCardWithOwner);

        URI locationOfNewCashCard = ucb
                .path("cashcards/{id}")
                .buildAndExpand(savedCashCard.id())
                .toUri();
        return ResponseEntity.created(locationOfNewCashCard).build();
    }

 

 

11: About CSRF

CashCardConfig.java

    @Bean
   SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http
        .authorizeHttpRequests(request -> request
        .requestMatchers("/cashcards/**")
        .hasRole("CARD-OWNER")) // Role 이 "CARD-OWNER" 이어야 한다!
        .csrf(csrf -> csrf.disable())
        .httpBasic(Customizer.withDefaults());

        return http.build();
   }

 

설정 클래스에 현재는 CSRF 보안을 비활성화 해놨다.

 

스프링 시큐리티 팀의 조언은 다음과 같다:

CSRF 는 클라이언트가 보통 유저 즉 웹 브라우저일때 킨다.

만약 클라이언트가 non-browser 이라면 (서버끼리 통신하는것 같은 경우) 끄는것이 좋다.

 

CSRF 관련 링크:

https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/csrf.html

 

Testing with CSRF Protection :: Spring Security

When testing any non-safe HTTP methods and using Spring Security’s CSRF protection, you must include a valid CSRF Token in the request. To specify a valid CSRF token as a request parameter use the CSRF RequestPostProcessor like so:

docs.spring.io

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함