Java/Spring Security

SecurityFilterChain과 AuthenticationProvider 구현

마손리 2023. 5. 13. 19:48

SecurityFilterChain

HttpSecurity 인스턴스를 매개변수로 받아 SecurityFilterChain으로 만들어준다. 

 

이전 포스트, Spring Security Filter의 동작 흐름에 기재된 것과 같이 SecurityFilterChainDelegatingFilterProxyFilterChainProxy를 통해 Servlet filter와 연결된다.

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http
                .headers().frameOptions().sameOrigin() // (1)
                .and() // (2)
                .csrf().disable() // (3)
                .formLogin() // (4)
                .loginPage("/auths/login-form") // (5)
                .loginProcessingUrl("/process_login") // (6)
                .failureUrl("/auths/login-form?error") // (7)
                .and() 
                .logout() // (8)
                .logoutUrl("/logout") // (9)
                .logoutSuccessUrl("/") // (10)
                .and()
                .exceptionHandling().accessDeniedPage("/auths/access-denied") // (11)
                .and()
                .authorizeHttpRequests(auth-> auth // (12)
                        .antMatchers("/orders/**").hasRole("ADMIN") // (12-1)
                        .antMatchers("/members/my-page").hasRole("USER") // (12-2)
                        .antMatchers("/**").permitAll() // (12-3)
                );

        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() { // (13)
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();  
    }
}

 

  • (1)을 통해 H2 웹 콘솔을 정상적으로 사용이 가능하다.
  • frameOptions()는 <frame>, <iframe>, <object>와 같은 HTML 태그에서 페이지를 렌더링 할지의 여부를 결정하는 기능을 한다.
    Spring SecurityClickjacking 공격을 막기위해 기본적으로 위의 태그들을 이용한 페이지 렌더링을 허용하지 않는다.
  • sameOrigin()은 동일한 출처로부터 들어오는 요청만 페이지 렌더링을 허용한다.
  • (2)의 and()메서드를 통해 해당 필터들을 체인 형태로 구성할 수 있다.

  • (3)에서는 CSRF(Cross-Site Request Forgery)공격에 한 설정을 비활성화 하고 있다.  CSRF이란 위조 요청에 관한 해킹 방법으로 Spring Security에서는 default로 CSRF 토큰이 없는 요청에는 예외를 발생시킨다. 
  • 해당 어플리케이션은 CSRF토큰으로 쓰일 OAuth2나 JWT등의 인증정보를 사용하지 않고 로컬 환경에서만 가동하므로 CSRF 보안을 비활성화 해주었다.

  • (4)의 formLogin()을 통해 기본적인 인증 방법을 폼 로그인 방식으로 지정
  • (5)의 loginPage("/auths/login-form") 메서드를 통해 로그인페이지(Get 요청)의 주소를 지정
  • (6)의 loginProcessingUrl("/process_login") 메서드를 통해 로그인 인증 요청(Post 요청)을 수행할 요청 URI를 지정
    이후 해당 URL로 들어온 Post 요청은 이전 포스트에서 언급된 UsernamePasswordAthenticationFilter로 들어오며 인증처리 과정을 거침
  • (7)의 failureUrl("/auths/login-form?error") 메서드를 통해 로그인 인증에 실패할 경우 리다이렉트될 주소 지정

  • (8)의 logout()을 사용하여 로그아웃에 관한 필터를 설정해 준다. 해당 메서드는 LogoutConfigurer 객체를 리턴한다.
  • (9)의 logoutUrl("/logout")을 이용하여 로그아웃을 수행할 요청 URL을 지정한다.
  • (10)의 logoutSuccessUrl("/")은 로그아웃 이후 리다이렉트할 주소를 지정한다.

 

  • 권한이 없는 사용자가 특정 URI에 접근할 경우 403에러가 발생하며, (11)의 exceptionHandling().accessDeniedPage("/auths/access-denied")은 해당 에러가 발생했을때 클라이언트를 리다이렉트 시킬 주소를 지정한다.
  • exceptionHandling() 메서드는 이름 그대로 Exception을 처리하는 메서드이며 ExceptionHandlingConfigurer 객체를 반환한다. 또한 해당 객체를 통해 구체적인 Exception을 처리할 수 있다. 
  • (12)의 authorizeHttpRequests()메서드는 람다식을 통해 request URI에 대한 접근 권한을 부여할 수 있다. 
  • (12-1)의 antMatchers("/orders/**").hasRole("ADMIN)은 ADMIN이라는 role을 부여받은 사용자만 "/orders"로 시작하는 모든 URL에 접근 가능하다는 의미이다.
    만약 해당 URL을 " /orders/* "라 지정한다면 "/orders"의 하위 URL의 depth가 한단계인 URL만 포함된다.
  • (12-2)의 경우는 마찬가지로 USER라는 권한을 부여받은 사용자만이 해당 URL인 "/members/my-page"에 접근할 수 있다.
  • (12-3)의 antMatchers("/**").permitAll()은 앞에서 지정한 다른 특정 URL들 이외의 모든 URL은 권한에 상관없이 접근이 가능하다.
  • 위와 같이 antMatchers() 메서드를 사용할시 순서가 매우 중요하므로 항상 더 구체적인 URL경로 즉, 하위 URL들의 접근 권한을 먼저 부여해야 한다.

  • (13)의 PasswordEncoder를 통해 패스워드를 암호화할 수 있다. (다음 블로그에 해당 내용 기재 예정)

 

SecurityFilterChain을 통해 클라이언트의 요청이 들어오면 다음 순서는 UsernamePasswordAthenticationFilter를 통해 사용자 인증에 들어간다. 이때 UsernamePasswordAthenticationFilterAthenticationProvider로 인증 처리를 지시하므로 AthenticationProvider를 만들어 준다.

 

 

AthenticationProvider

@Component
public class HelloUserAuthenticationProvider implements AuthenticationProvider { // (1)
    private final HelloUserDetailsServiceV1 userDetailsService;
    private final PasswordEncoder passwordEncoder;

    public HelloUserAuthenticationProvider(HelloUserDetailsServiceV1 userDetailsService,
                                           PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }
    
    // (2)
    @Override
    public boolean supports(Class<?> authentication) { 
        return UsernamePasswordAuthenticationToken.class.equals(authentication); 
    }

	// (3)
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authToken = 
                (UsernamePasswordAuthenticationToken) authentication; // (3-1)

        String username = authToken.getName(); // (3-2)
        Optional.ofNullable(username).orElseThrow(()->new UsernameNotFoundException("Invalid Username"));

        try{
        	// (3-3)
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            String password = userDetails.getPassword();
            verifyCredentials(authToken.getCredentials(), password); // (3-4)
            
            // (3-5)
            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
            
            // (3-6)
            return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
        }catch (Exception ex){
            throw new UsernameNotFoundException(ex.getMessage());
        }
    }

    private void verifyCredentials(Object credentials, String password ){
        if(!passwordEncoder.matches((String) credentials,password)){
            throw new BadCredentialsException("Invalid Password");
        }
    }
}
  • (1)과 같이 AuthenticationProvider 인터페이스의 구현 클래스로 정의한다. Spring Security는 위의 코드와 같이 AuthenticationProvider를 구현한 구현 클래스가 Spring Bean으로 등록되어 있다면 해당 AuthenticationProvider를 이용해서 인증을 진행한다. 만약 AuthenticationProvider를 구현한 클래스가 없다면 Spring Security에서 제공한 AuthenticationProvider가 실행된다.
  • 따라서 클라이언트 쪽에서 로그인 인증을 시도하면 현재 구현된 HelloUserAuthenticationProvider가 직접 인증을 처리하게 된다.
    • AuthenticationProvider 인터페이스의 구현 클래스는 authenticate(Authentication authentication) 메서드와 supports(Class<?> authentication) 메서드를 구현해야 한다. supports() 메서드의 리턴값이 true일 경우, Spring Security는 해당 AuthenticationProvider의 authenticate() 메서드를 호출해서 인증을 진행한다.
    • 그중에서 (2)의 supports(Class<?> authentication) 메서드는 현재 코드에 구현된 Custom AuthenticationProvider(HelloUserAuthenticationProvider)가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려주는 역할을 한다.

    • (3)의 authenticate(Authentication authentication)에서 직접 작성한 인증 처리 로직을 이용해 사용자의 인증 여부를 결정한다. authentication은 UsernamePasswordAuthenticationToken 토큰이 Authentication업캐스팅된 매개변수이다.
      • (3-1)에서 authentication을 다시 다운 캐스팅하여 UsernamePasswordAuthenticationToken을 얻는다.
      • UsernamePasswordAuthenticationToken 객체에서 (3-2)와 같이 해당 사용자의 Username을 얻은 후, 존재하는지 체크한다.
      • Username이 존재한다면 (3-3)과 같이 userDetailsService를 이용해 데이터베이스에서 해당 사용자를 조회한다.
      • (3-4)에서 로그인 정보에 포함된 패스워드(authToken.getCredentials())와 데이터베이스에 저장된 사용자의 패스워드 정보가 일치하는지를 검증한다. (PasswordEncoder에 관해서는 다음 포스트에...)
      • (3-4)의 검증 과정을 통과했다면 로그인 인증에 성공한 사용자이므로 (3-5)와 같이 해당 사용자의 권한을 생성한다.
      • 마지막으로 (3-6)과 같이 인증된 사용자의 인증 정보를 리턴값으로 전달한다.
      • 이 인증 정보는 내부적으로 Spring Security에서 관리하게 된다.

 

try/catch문을 사용한 이유

(3-3)에서 뒤에 작성할 메서드인 userDetailsService.loadUserByUsername()username을 가지고 JpaRepository를 이용하여 해당 사용자를 찾게되는데 그 과정에서 에러가 발생할시 BusinessLogicException을 throw하게 된다. 그리고 throw된 BusinessLogicException은 Cusotm AuthenticationProvider를 거쳐 그대로 Spring Security 내부 영역으로 throw 되기 때문이다. 

 

문제는 Spring Security에서 인증 실패 시, AuthenticationException이 throw 되지 않으면 Exception에 대한 별도의 처리를 하지 않고, 서블릿 컨테이너인 톰캣 쪽으로 이 처리를 넘기게 된다.

 

하지만 try/catch문을 사용하여 UsernameNotFoundException(AuthenticationException을 상속받음)을 throw해주면 SecurityFilterChain 코드에서 작성한 failureUrl("/auths/login-form?error") 메서드가 실행된다. (직접 호출하는 것이 아닌 failureUrl 메서드로 설정된 SecurityFilterChain이 작업을 수행)

 

Custom AuthenticationProvider에서 AuthenticationException이 아닌 Exception이 발생할 경우에는 꼭 AuthenticationException을 rethrow 하도록 코드를 구성해야 한다!

 

 

이제 Spring Security의 인증 처리 흐름에서 알아 보았듯이 UserDetailsService를 구현하여 DB에서 특정 사용자의 정보를 꺼내 (3-3)에 사용된 UserDetails를 만들어주어야한다. 

 

 

UserDetailsService

UserDetailsService의 역할은 간단하다. AthenticationProvider에서 건내받은 username을 이용하여 DB에서 특정 사용자를 찾아낸뒤 UserDetails 객체로 변환하여 리턴해준다. 

 

여기서 username은 해당 어플리케이션에 로그인하기위해 사용되는, 흔히말하는 아이디에 해당되며 해당 포스트에서의 username은 회원의 email에 해당된다.

 

@Component
public class HelloUserDetailsServiceV1 implements UserDetailsService { // (1)
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    // (2)
    public HelloUserDetailsServiceV1(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

    // (3)
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username); // (3-1)
        Member findMember = optionalMember.orElseThrow(() ->
                new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); // (3-2)

        return new HelloUserDetails(findMember);  
    }

    // (4)
    private final class HelloUserDetails extends Member implements UserDetails { // (4-1)
        // (4-2)
        HelloUserDetails(Member member) {
            setMemberId(member.getMemberId());
            setFullName(member.getFullName());
            setEmail(member.getEmail());
            setPassword(member.getPassword());
            setRoles(member.getRoles());
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorityUtils.createAuthorities(this.getRoles());  // (4-3) 
        }

        // (4-4)
        @Override
        public String getUsername() {
            return getEmail();
        }

        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
        @Override
        public boolean isEnabled() {
            return true;
        }
    }
}
  • HelloUserDetailsService와 같은 Custom UserDetailsService를 구현하기 위해서는 (1)과 같이 UserDetailsService 인터페이스를 구현해야 한다.
  • HelloUserDetailsService는 데이터베이스에서 User를 조회하고, 조회한 User의 권한(Role) 정보를 생성하기 위해 (2)와 같이 MemberRepository와 HelloAuthorityUtils 클래스를 DI 받는다.

  • UserDetailsService 인터페이스를 implements 하는 구현 클래스는 (3)과 같이 loadUserByUsername(String username)이라는 추상 메서드를 구현해야 한다.
  • (3-1)과 같이 매개변수로 받은 username을 이용하여 DB에서 해당 사용자를 검색한다.
  • 만약 DB에서 사용자를 찾지 못할경우 (3-2)와 같이 BusinessLogicException이 발생한다. BusinessLogicException을 Spring Security안에서 처리하기 위해 AthenticationProvider에서 try/catch문을 사용하여 AuthenticationException으로 rethrow해준다.

  • (4)에서는UserDetails를 구현하는 클래스를 작성해준다.
  • (4-1)과 같이 HelloUserDetails클래스는 UserDetails 인터페이스를 구현하고 Member 엔티티 클래스까지 상속하고 있다.
    UserDetails에서는 getAuthorities(), getUsername(), getPassword() 메서드가 필요한데 getPassword()메서드는 Member 엔티티에 이미 작성되 있으므로 getAuthorities(), getUsername() 메서드만 작성해 주면된다. 
  • 또한 (4-2)와 같이 Member의 setter 메서드들을 사용하여 HelloUserDetails객체 자신의 필드들을 생성자를 이용하여 자동으로 채워 줄수 있게된다. 
  • Member 엔티티의 권한 정보를 담은 필드인 rolesList<String>타입이다. 하지만 UserDetails의 객체는 List<GrantedAuthorit> 타입에 권한 정보를 담아야 하므로 HelloAuthorityUtils 클래스를 만들어 해당 타입을 맵핑하는 메서드를 만들고 (4-3)과 같이 getAuthorities()메서드 호출시 타입이 변환되어 반환되도록 만들어 준다.
  • 해당 어플리케이션은 사용자의 email을 username으로 사용하므로 getUsername()메서드를 호출할시 (4-4)와 같이 사용자의 email을 반환하도록 작성한다. 
    getEmail() 메서드는 상위 클래스인 Member에 이미 작성되있으므로 생략 가능하다.

이후 반환된 UserDetails는 AuthenticationProvider에서 username, password, autorities를 추출하여  UsernamePasswordAuthenticationToken 객체에 인증정보와 함께 업데이트 시킨뒤 Authentication으로 업캐스팅 한후 반환한다.

 

 

권한 정보 맵핑 (HelloAuthorityUtils)

HelloAuthorityUtils 클래스는 위에서 사용자의 권한 정보를 얻기 위한 메서드인 getAuthorities()를 위한 맵퍼인 createAuthorities() 메서드와 사용자 등록을 위한 post 요청시 Service 계층에서 DB에 생성될 데이터인 Member 엔티티에 권한 정보를 넣어줄 createRoles() 메서드를 정의해 주었다.

@Component
public class HelloAuthorityUtils {
    // (1)
    @Value("${mail.address.admin}")
    private String adminMailAddress;
    
    // (2)
    private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
    private final List<String> USER_ROLES_STRING = List.of("USER");

	// (3)
    public List<String> createRoles(String email) {
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES_STRING;
        }
        return USER_ROLES_STRING;
    }
    
    // (4)
    public List<GrantedAuthority> createAuthorities(List<String> roles) { 
        List<GrantedAuthority> authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // (4-1)
                .collect(Collectors.toList());
        return authorities;
    }
}
  • application.yml에 추가한 어플리케이션 환경변수를 가저오는 표현식이다. 
    (1)과 같이 @Value("${프로퍼티 경로}") 형태로 작성하면 application.yml에 정의되어 있는 프로퍼티의 값을 클래스 내에서 사용할 수 있다.
  • application.yml :
...
...
mail:
  address:
    admin: test@test.test

 

  • (2)에서는 Member 엔티티에 들어갈 권한 정보를 List<String>타입의 상수형으로 지정해준다.
  • (3)의 createRoles() 메서드는 생성되는 사용자의 email을 파라미터로 받아 어플리케이션의 환경변수와 비교후 적절한 권한 정보를 Member 엔티티의 roles 필드에 등록해준다. 

  • (4)에서는 UserDetailsService에서 인증 처리중인 사용자의 String 타입의 권한 정보를 받아 SimpleGrantedAuthority 객체를 생성하여 GrantedAuthority 타입으로 변환해준다. 
    이때 생성자 파라미터로 넘겨주는 값이 USER" 또는 “ADMIN"으로 넘겨주면 안 되고 ROLE_USER" 또는 “ROLE_ADMIN" 형태로 넘겨주어야 한다.

 

사용된 Member 엔티티

@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long memberId;

    @Column(length = 100, nullable = false)
    private String fullName;

    @Column(nullable = false, updatable = false, unique = true)
    private String email;

    @Column(length = 100, nullable = false)
    private String password;

    @Enumerated(value = EnumType.STRING)
    @Column(length = 20, nullable = false)
    private MemberStatus memberStatus = MemberStatus.MEMBER_ACTIVE;

    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

    public Member(String email) {
        this.email = email;
    }

    public Member(String email, String fullName, String password) {
        this.email = email;
        this.fullName = fullName;
        this.password = password;
    }

    public enum MemberStatus {
        MEMBER_ACTIVE("활동중"),
        MEMBER_SLEEP("휴면 상태"),
        MEMBER_QUIT("탈퇴 상태");

        @Getter
        private String status;

        MemberStatus(String status) {
           this.status = status;
        }
    }
}

 

 

소스코드: https://github.com/Mason3144/templete-spring-security

'Java > Spring Security' 카테고리의 다른 글

JWT  (0) 2023.05.17
DelegatingPasswordEncoder  (0) 2023.05.15
Spring Security의 인증 처리 흐름  (0) 2023.05.13
Servlet Filter 구현  (0) 2023.05.13
Spring Security Filter의 동작 흐름  (1) 2023.05.13