Hyun's Wonderwall

[GDSC Study] 스프링 기반 REST API 개발 - 5주차 | 섹션 5. REST API 보안 적용 본문

Study/Java, Spring

[GDSC Study] 스프링 기반 REST API 개발 - 5주차 | 섹션 5. REST API 보안 적용

Hyun_! 2023. 12. 27. 16:25

GDSC Ewha 5기_ Spring Boot 스터디

  • 스터디 커리큘럼: 백기선, "스프링 기반 REST API 개발"
  • 5주차 과제 - 섹션 5. REST API 보안 적용
    5. (1) Account 도메인 추가
    5. (2) 스프링 시큐리티 적용
    5. (3) 예외 테스트
    5. (4) 스프링 시큐리티 기본 설정
    5. (5) 스프링 시큐리티 폼 인증 설정
    5. (6) 스프링 시큐리티 OAuth2 인증 서버 설정
    5. (7) 리소스 서버 설정
    5. (8) 문자열을 외부 설정으로 빼내기
    5. (9) 이벤트 API 점검
    5. (10) 현재 사용자 조회
    5. (11) 출력값 제한하기

현재까지 우리가 지금 만든 API는 인증 절차가 없어서 아무리 POST 요청을 보내면 이벤트를 만들 수 있다.

인증된 사용자만 이벤트를 만들게 해야하고 이벤트 수정 권한도 제한을 두어야 한다.

 

Spring Security Oauth 2.0 사용. password라는 Grant 타입 사용.

user가 데이터베이스에서 키워드로 선언되어있어서 user라고 안하고 (@Table("")할 때 못씀) Account 사용하겠음.

Account 도메인 추가하자.

accounts 디렉터리, Account 클래스.

AccountRole enum 만들고 Account에 해당 타입으로 Set 생성. Annotation들 새로 등장.

//Account 클래스
package me.lsh.restapidemo.accounts;

import jakarta.persistence.*;
import lombok.*;

import java.util.Set;

@Entity
@Getter @Setter @EqualsAndHashCode(of="id")
@Builder @NoArgsConstructor @AllArgsConstructor
public class Account {

    @Id @GeneratedValue
    private Integer id;
    private String email;
    private String password;
    @ElementCollection(fetch=FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    private Set<AccountRole> roles;
}

role 두가지- ADMIN, USER

//AccountRole 이넘
package me.lsh.restapidemo.accounts;

public enum AccountRole {
    ADMIN, USER
}

 

Event에 단방향 매핑. Event에서만 owner를 참조할 수 있도록.

 //Event 클래스에 추가
    @ManyToOne
    private Account manager;

2. 스프링 시큐리티 살펴보고 의존성 추가한 다음 유저 디테일 서비스 구현하자

 

스프링 시큐리티 기능 2개: 웹 시큐리티, 메소드 시큐리티

모두 공통된 인터페이스 Security Interceptor 통해 기능 제공함.

 

*웹 시큐리티: 웹 요청에 보안 인증을 하는 것.

- Security Interceptor는 웹 요청 같은 경우 Security Filter Chain이 서블릿 필터와 연관되어있음. 필터 기반의 시큐리티가 적용되어 있는 것.

- 웹: 서블릿 기반만 얘기 하는 중

 

*메소드 시큐리티: 웹과 상관없이 어떤 메서드가 호출됐을 때 그때 인증 또는 권한을 확인해 주는 시큐리티.

- AOP. 프록시를 만들어서 접근과 보안을 강제하는, 인터셉터가 앞에서 일함.

 

따라서 인터셉터 구현 체계가 두 개: 필터 시큐리티 인터셉트, 메서드 시큐리티 인터셉터

이번 강좌에서는 거의 웹 시큐리티

 

<Security Interceptor 동작 흐름>

1. 요청 들어옴

2. 요청을 서블릿 필터가 가로채 웹 필터 Security Interceptor 쪽으로 요청보냄

3. Security Interceptor가 요청보고 이 요청의 인증을 해야하는지, 필터 적용을 할것인지 확인함.

-> 요청에 필터를 적용해야 하면 필터 인터셉트에 들어옴.

4. 인터셉트에 들어오면 인증 정보를 먼저 확인함. 인증 정보를 Security Context Holder라는 ThreadLocal 구현체에서 인증 정보를 꺼내려고 시도한다.

(ThreadLocal 구현체: 자바 SDK에 들어있는, 한 스레드 내에서 공유하는 저장소. 한 스레드에서 실행되는 메소드라면 굳이 메소드 파라미터로 데이터 넘겨주지 않아도 되는 장점. 스레드 로컬에 넣어놓고 다른 메소드에서 꺼내씀.)

-> 꺼내면 인증된 사용자가 이미 있는 거고 없으면 인증을 한 적이 없는 것.(사용자가 없)

 

*사용자가 없으면 - 일단 Authentication Manager를 사용해서 로그인한다.

* Authentication Manager가 로그인 할 때 사용하는 주요한 인터페이스 2개: User Details 서비스, 패스워드 인코더.

Authentication Manager의 대표적인 인증 방법: Basic Authentication.

인증 요청 헤더에 Authentication 그리고 Basic 그 다음에 유저네임 패스워드를 합쳐서 인코딩한 그 문자를 가지고 거기서 유저네임과 패스워드를 입력받는다. (??)

입력을 받았을 때 User Details Service 인터페이스를 사용해 입력받은 유저네임에 해당하는 패스워드를 데이터베이스 등에서 읽어온다. 읽어온 다음, 읽어온 패스워드와 사용자가 입력한 패스워드가 매칭하는지 패스워드 인코더로 검사한다. 그래서 그게 매칭이 되면 로그인이 된 거고, 그럼 이제  Authentication 객체를 만들어서 Security Context Holder에 저장한다.

 

요약: Security Interceptor가 Authentication Manager를 사용해서 인증을 함. 인증 정보는 Security Context Holder에 담아놓는다.

 

이제, 인증이 됐다면 권한이 적절한지 확인한다.  요청한 리소스에 접근할 권한이 충분한가.

유저의 role을 활용한다.

현재 인증된 그 어카운트의 롤이 지금이 요청을 사용할 수 있는가, 이 리소스를 접근할 수 있는가를 확인하고 거기에 따른 예외처리같은게 많음 <- 스프링 세큐리티

이렇게 해서 인증과 Authentication과 Authorization이 처리가 되는 것. Interceptor 기준으로.

 

이런 모든 기능을 사용하려면 의존성을 추가해야 한다.

Spring Security 의존성 추가

 

User Details Sevice 구현하자

//AccountRepository 인터페이스
package me.lsh.restapidemo.accounts;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Integer> {
    Optional<Account> findByEmail(String username); //null을 리턴할 수 있으니까
}
//AccountService 클래스
package me.lsh.restapidemo.accounts;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;

@Service
public class AccountService implements UserDetailsService {
    @Autowired
    AccountRepository accountRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = accountRepository.findByEmail(username)
                .orElseThrow(); //이 유저에 해당하는 객체가 없으면 에러 던짐
        return new User(account.getEmail(), account.getPassword(), authorities(account.getRoles()));
    }

    private Collection<? extends GrantedAuthority> authorities(Set<AccountRole> roles) {
        return roles.stream()
                .map(r -> new SimpleGrantedAuthority("ROLE" + r.name()))
                .collect(Collectors.toSet());
    }
}
//AccountServiceTest 테스트
package me.lsh.restapidemo.accounts;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Set;

@RunWith(SpringRunner.class)
@SpringBootTest
@ActiveProfiles("test")
public class AccountServiceTest {
    @Autowired
    AccountService accountService;

    @Autowired
    AccountRepository accountRepository;

    @Test
    public void findByUsername() {
        String password = "keesun";
        String username = "keesunn@gmail.com";
        Account account = Account.builder()
                .email(username)
                .password(password)
                .roles(Set.of(AccountRole.ADMIN, AccountRole.USER))
                .build();
        this.accountRepository.save(account);

        //When
        UserDetailsService userDetailsService = accountService;
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        //Then
        assertThat(userDetails.getPassword()).isEqualTo(password);
    }
}

우리가 사용하는 도메인을 Spring Security가 정의해놓은 인터페이스로 변환하는 일을 함. 


(3) 예외 테스트

세 가지 방법

1.

    @Test(expected = UsernameNotFoundException.class)
    public void findByUsernameFail() {
        String username = "random@gmail.com";
        accountService.loadUserByUsername(username);
    }
//이렇게 하면 예외의 타입밖에 확인하지 못함

2.

    @Test
    public void findByUsernameFail() {
        String username = "random@gmail.com";
        try {
            accountService.loadUserByUsername(username);
            fail("supposed to be failed");
        } catch (UsernameNotFoundException e) {
            assertThat(e.getMessage()).containsSequence(username);
        }
    }
//실제 예외 메세지까지 확인 가능, 더 많은 것 테스트 가능

3.

    @Rule
    public ExpectedException expectedException = ExpectedException.none();
    //비어있는 exception으로 등록
    
    /*중략..*/
    @Test
    public void findByUsernameFail() {
        // Excepted
        String username = "random@gmail.com";
            // 예측되는 예외를 먼저 적어줘야 함(UNFE, thrown)
        expectedException.expect(UsernameNotFoundException.class);
        expectedException.expectMessage(Matchers.containsString(username));
        // When - 다르면 test 실패
        accountService.loadUserByUsername(username);
    }

(4)

스프링 시큐리티를 의존성에 추가하게 되면 이제 모든 요청들은 인증을 필요로 하게 됨 기존에 만들었던 컨트롤러 테스트들 다 깨짐! (스프링 부트가 스프링 시큐리티 자동 설정을 모든 요청이 다 인증을 필요로 하게 했음) 그리고 스프링 시큐리티가 임의로 사용자를 하나 인메모리로 만들어줌.

 

우리의 목표: 

 

스프링부트 3 버전에 맞게 설정해야 함.

 

참고 1: https://covenant.tistory.com/277

참고 2:

https://www.inflearn.com/questions/762902/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-3%EB%B2%84%EC%A0%84-%EC%82%AC%EC%9A%A9%EC%8B%9C-%EC%B0%B8%EA%B3%A0%ED%95%98%EC%84%B8%EC%9A%94