데블 아니고 데블리

운동,햄버거, 개발 좋아요

🐷💻📝

프로그래밍/스프링

Spring Security 구조 복습 with 로그인은 어떻게 구현되는가 (1) ?

데블아니고데블리 2024. 6. 4. 17:12

[정리하게 된 계기]

시큐리티.. 스프링 하위 프레임워크라고 생각하면 되는데,

토큰 만들면서 Filter의 흐름대로 처리하고는 있지만..

어느 시점에 실행되고 구조는 어떻게 이루어져 있는지 깊게 공부하지는 못했던 것 같다

특히 403에러가 많이 나오는데, 정말 권한이 없어서 그런건지.. 이런 것들이 궁금해져서 작성해 보기로 한다

이번에 정리해 본 후 로그인 기능까지 정리해 보자

*https://webfirewood.tistory.com/115 여기 블로그 참고했고, 코드는 제 코드로 작성했습니다~

 

[스프링 시큐리티 (인증, 인가, 필터, 인터셉터)]

Spring Security 는 spring 기반의 어플리케이션의 보안(인증,인가) 등을 담당하는 스프링 하위 프레임워크이다

스프링 시큐리티는 보안을 크게

- 인증(Authentication) : 해당 사용자가 본인이 맞는지를 확인하는 과정, 로그인 했니, 안했니

- 인가(Authorization) : role, 해당 사용자가 요청한 사용을 사용할 수 있는지를 담당 예: 일반 사용자가 admin 계정 정보 볼 수 없다

두 가지로 보안을 구성한다.

기본적으로 인증 절차를 거친 후에 인가 절차를 진행하며, 인가 과정에서 해당 리소스에 접근 권한이 있는지 확인하게 된다.

 

또한 스프링 시큐리티에서 필터와 인터셉터는 모두 요청을 가로채고 처리하는 데 사용되는데, 목적과 사용방식에 차이가 있어서 집고 넘어가도록 하겠다.

- 필터 (Filter) : Http 요청과 응답을 가로채고 처리하기(웹 컨테이너)

  • 요청 전 - 후 처리 : 요청이 서블릿에 도착하기 전, 응답이 클라이언트에 반환되기 전에 실행
  • 체인 구조 : 여러 필터가 체인 형태로 연결되어 순차적으로 실행
  • 인증 / 인가는 필터를 통해 구현

- 인터셉터 (Interceptor) : MVC에서 제공, 컨트롤러의 핸들러 매소드가 호출되기 전후에 특정 작업을 수행하기 위해 사용, 요청을 로깅하거나 특정 조건에 따라 요청을 차단하는 등의 작업 수행

  • 핸들러 전 - 후 처리 : 컨트롤러의 핸들러 메소드가 호출되기 전, 호출된 후 그리고 뷰가 렌더링 된 후 실행
  • 핸들러 수준에서 동작 : 특정 컨트롤러, 핸들러 메소드에 대해 동작하도록 한다
  • 로깅, 인증, 권한검사(요청의 전처리, 후처리, 완료처리)

*핸들러 : http 요청을 처리하는 컨트롤러 내의 메소드

기초적인 정리가 되었으면 본격적으로 구조에 대해서 알아보도록 하자

 

[스프링 시큐리티 구조]

 

코드를 통해서 알아보겠다

1. 사용자가 로그인 정보와 함께 인증 요청을 한다.(Http Request)

// UserService
@Transactional
    public LoginTokenDto userLoginProcess(UserLoginDto requestDto) {
        // 1. 사용자가 로그인 정보와 함께 인증 요청을 한다. UserLoginDto 로 받는다
        if (!hasText(requestDto.getLoginId()) || !hasText(requestDto.getPassword())) {
            throw new RuntimeException("로그인 정보를 입력해주세요.");
        }
}


2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성한다.

// UserService 
try {
            Authentication authentication = memberAuthenticationProvider.authenticate(
                    new UsernamePasswordAuthenticationToken(requestDto.getLoginId(), requestDto.getPassword()));
                    }


3. AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다

4. AuthenticationManager는 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구한다. 

@Component
@RequiredArgsConstructor
public class MemberAuthenticationProvider implements AuthenticationProvider {

    private final MemberDetailsService memberDetailsService;
    private final PasswordEncoder passwordEncoder;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        // 3. AuthenticationManager의 구현체인 ProviderManager에게 생성한 UsernamePasswordToken 객체를 전달한다.
        // 4. AuthenticationManager는 등록된 AuthenticationProvider(들)을 조회하여 인증을 요구한다.
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        // 아이디 없으면 UsernameNotFoundException 발생

        //5. 실제 DB에서 사용자 인증정보를 가져오는 UserDetailsService(여기서는 memberDetailsService) 에 사용자 정보를 넘겨준다.
        User user = (User) memberDetailsService.loadUserByUsername(username);
        }


5. 실제 DB에서 사용자 인증정보를 가져오는 UserDetailsService에 사용자 정보를 넘겨준다. 

6. 넘겨받은 사용자 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다. 

@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 6. 넘겨 받은 사용자 정보를 통해 DB 에서 찾은 사용자 정보인 UserDetails 객체를 만든다
        return userRepository.findByLoginId(username)
                .orElseThrow(() -> new UsernameNotFoundException("아이디가 존재하지 않습니다."));
    }
}


7. AuthenticationProvider(들)은 UserDetails를 넘겨받고 사용자 정보를 비교한다. 

➡️ 비밀번호 검증 등등
8. 인증이 완료되면 권한 등의 사용자 정보를 담은 Authentication 객체를 반환한다. 

return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());


9. 다시 최초의 AuthenticationFilter에 Authentication 객체가 반환된다. 

10. Authenticaton 객체를 SecurityContext에 저장한다.

 

* SecurityContextHolder는 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장하는데 이는 곧

스프링 시큐리티가 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다...


우선 각각의 모듈에 대해서 알아본 후 jwt 토큰 인증은 어떻게 만들어 지는지 확인해 보려고 한다.

1. Authentication

현재 접근하는 주체(user)의 정보와 권한을 담는 인터페이스

SecurityContextHolder 를 통해 SecurityContext 에 접근, 그 안에 있는 Authentication에 접근하게 된다

 

2. UsernameAndPasswordAuthenticationToken

사용자 인증 정보를 캡슐화하기 위해 사용되는 객체이다.

로그인 시 사용자 이름과 비밀번호를 포함한 인증정보를 처리하는 데 사용된다

- principal : 사용자 이름 , 사용자 정보를 나타내는 객체

- credentials : 사용자 비밀번호 또는 자격증명을 나타내는 객체

// UsernamePasswordAuthenticationToken 클래스
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
  private static final long serialVersionUID = 620L;
  private final Object principal;
  private Object credentials;

// 인증되지 않은 사용자의 인증 정보를 생성하는 생성자, 로그인 폼에서 아이디, 비밀번호를 전달받아 사용
  public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
    super((Collection)null);
    this.principal = principal;
    this.credentials = credentials;
    // 초기 상태를 "인증되지 않음"으로 설정
    this.setAuthenticated(false);
  }

// 인증된 사용자의 인증 정보를 생성하는 생성자, 인증이 성공한 후, 사용자의 권한 정보를 포함하여 생성
  public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
    super(authorities);
    this.principal = principal;
    this.credentials = credentials;
    // 이후 인증됨으로 설정
    super.setAuthenticated(true);
  }

  public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
    return new UsernamePasswordAuthenticationToken(principal, credentials);
  }

  public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
    return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
  }

 

3. AuthenticationManager

인증은 AuthenticationManager를 통해서 처리하게 되는데, 

실질적으로는 AuthenticationManager에등록된 AuthenticationProvider에(4번) 의해 처리된다.

인증에 성공하면 객체를 생성하여 SecurityContext에 저장한다.

 

4. ⭐️AuthenticationProvider

@Component
@RequiredArgsConstructor
//AuthenticationProvider 를 상속받게 되면
public class MemberAuthenticationProvider implements AuthenticationProvider {

    private final MemberDetailsService memberDetailsService;
    private final PasswordEncoder passwordEncoder;

// authenticate 매소드를 구현하게 되는데, 사용자 정보를 받아와서 
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        // 아이디 없으면 UsernameNotFoundException 발생

		// 여기서 6번의 UserDetailsService(나는 MemberDetailsService로 구현) 호출하여 사용자 정보 받아온다
        User user = (User) memberDetailsService.loadUserByUsername(username);

        if (!passwordEncoder.matches(password, user.getPassword()))
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");

        return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
    }

AuthenticationProvider에서는 실제 인증에 대한 부분을 처리하는데, 인증 전의 Authentication 객체를 받아서 인증이 완료된 객체를 반환하는 역할을 한다. 아래와 같은 인터페이스를 구현해 Custom한 AuthenticationProvider를 작성하고 AuthenticationManager에 등록하면 된다.

 

5. ProviderManager

AuthenticationManager를 implements한 ProviderManager는 AuthenticationProvider를 구성하는 목록을 갖는다.

 

6. userDetailsService(DB랑 연결하는 역할)

* MemberDetailsService 라는 이름으로 구현하였습니다.

@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return userRepository.findByLoginId(username)
                .orElseThrow(() -> new UsernameNotFoundException("아이디가 존재하지 않습니다."));
    }
    public UserDetails loadUserById(Long id) throws UsernameNotFoundException {
        return userRepository
                .findById(id)
                .orElseThrow(() -> new UsernameNotFoundException("회원 정보를 찾을 수 없습니다."));
    }

}

UserDetails 객체를 반환하는 하나의 메소드만을 가지고 있는데, 일반적으로 이를 implements한 클래스에 UserRepository를 주입받아 DB와 연결하여 처리한다.

 

7. UserDetails

* 저는 UserEntity 에서 상속(implements) 하여 구현하였습니다.

@Entity
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
@Data
@Accessors(chain = true)
@Table(name = "user")
// UserDetails 를 상속받아 구현하면
public class User implements UserDetails {

// user 객체 정의

// UserDetails 구현
@Override
// 10번의 GrantedAuthority(인가)도 확인할 수 있습니다.
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

    @Override
    public String getUsername() {
        return this.loginId;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }

}

인증에 성공하여 생성된 UserDetails 객체는 Authentication객체를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용된다. UserDetails를 implements하여 처리할 수 있다.

 

8. SecurityContextHolder

보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 텍스트에 대한 세부 정보가 저장된다.

 

9. SecurityContext

Authentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication을 저장하거나 꺼내올 수 있다.

 

10. GrantedAutority 

*UserDetailsService 안에 있는 구현체

현재 사용자(Principal)가 가지고 있는 권한이며,,, ROLE_*의 형태로 사용한다. GrantedAuthority 객체는 UserDetailsService에 의해 불러올 수 있고, 403! 권한과 관련된 에러와 관련있다..