[Spring] Spring Sequrity 기본 구조

이동엽·2023년 3월 20일
0

기본 구조

-조건

SSR방식임 : 서버에서 HTML을 만들어 클라이언트 쪽으로 내려주는 방식

예제 애플리케이션 구조

1-1 코드(MemberController코드)

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Controller
@RequestMapping("/members")
public class MemberController {
    private final MemberService memberService;
    private final MemberMapper mapper;

    ...
		...

    // 회원 가입 폼에서 전송한 회원 정보가 DB에 저장되는 핸들러 메소드
    @PostMapping("/register")
    public String registerMember(@Valid MemberDto.Post requestBody) {
        Member member = mapper.memberPostToMember(requestBody);
        memberService.createMember(member);

        System.out.println("Member Registration Successfully");
        return "login";
    }
}

registerMember() 핸들러 메서드를 통해 회원 가입 폼에서 전송한 회원 정보가 우리가 잘 알고 있는 서비스 계층과 데이터 액세스 계층을 거쳐서 데이터베이스에 저장됩니다.

1-2 코드(AuthController)

package com.codestates.auth;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/auths")
public class AuthController {
    @GetMapping("/login-form")
    public String loginForm() {
        return "login";
    }

    @GetMapping("/access-denied")
    public String accessDenied() {
        return "access-denied";
    }

    // 로그인 버튼 누르면 이 메소드 요청됨
    @PostMapping("/login")
    public String login() {
        System.out.println("Login successfully!");
        return "home";
    }
}

Spring Security Configuration 적용

적용하면 우리가 원하는 인증 방식과 웹페이지에 대한 접근 권한을 설정할 수 있다.

InMemory User로 인증하기

package com.codestates.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class SecurityConfiguration {
    @Bean
    public UserDetailsManager userDetailsService() {
        // (1) 
        UserDetails userDetails =
                User.withDefaultPasswordEncoder()    // (1-1)
                        .username("kevin@gmail.com") // (1-2)
                        .password("1111")            // (1-3)
                        .roles("USER")               // (1-4)
                        .build();
        // (2)User 클래스를 이용해서 사용자의 인증 정보를 생성한다
        return new InMemoryUserDetailsManager(userDetails);
    }
}

InMemory Single User 인증 정보 설정

전에는 실행 할때마다 랜덤으로 생성되는 패스워드를 이용해야했는데

사용자 계정 정보를 메모리상에 지정했기때문에 사용자 계정 정보가 안 바뀐다.

코드 설명

UserDetails인터페이스는 인증된 사용자의 핵심 정보를 포함하고 있다.

1-1 withDefaultPasswordEncoder()는 디폴트 패스워드 인코더를 이용해 사용자 패스워드를 암호화합니다. →• (1-3)의 password() 메서드의 파라미터로 전달한 “1111”을 암호화해 줍니다.

1-2 이메일을 username으로 지정

1-3 사용자의 password를 설정

1-4 역할을 지정하는 메소드 : (ex) 일반 사용자,관리자)

  • UserDetailsManager 인터페이스 : 사용자의 핵심 정보를 포함한 UserDetails를 관리함

하지만 이 코드에선 메모리상에서 UserDetails를 관리 하기에 InMemoryUserDetailsManager라는 구현체 쓴다.

(2) new InMemoryUserDetailsManager(userDetails)를 통해 UserDetailsManager객체를 Bean으로 등록하면 Spring에서 해당 Bean이 가지고 있는 사용자 인증 정보가 클라이언트의 요청으로 넘어 올경우에 정상적인 인증 프로세스를 수행한다.

HTTP 보안 구성

기본

@Configuration
public class SecurityConfiguration {
    // (1)
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // HttpSecurity를 통해 HTTP 요청에 대한 보안 설정을 구성한다.
        ...
        ...
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("kevin@gmail.com")
                        .password("1111")
                        .roles("USER")
                        .build();
        return new InMemoryUserDetailsManager(user);
    }
}

(1) HttpSecurity를 파라미터로 가지고, SecurityFilterChain을 리턴하는 형태의 메서드를 정의하면 HTTP 보안 설정을 구성할 수 있습니다.

  • HttpSecurity는 HTTP요청에 대한 보안 설정을 구성하기 위한 핵심 클래스다.

커스텀 로그인 페이지 지정하기

커스텀 로그인 페이지 설정

package com.codestates.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()                 // (1)
            .formLogin()                      // (2)
            .loginPage("/auths/login-form")   // (3) 페이지 사용
            .loginProcessingUrl("/process_login")    // (4)
            .failureUrl("/auths/login-form?error")   // (5)
            .and()                                   // (6)
            .authorizeHttpRequests()                     // (7)
            .anyRequest()                            // (8)
            .permitAll();                            // (9)

        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("kevin@gmail.com")
                        .password("1111")
                        .roles("USER")
                        .build();
        return new InMemoryUserDetailsManager(user);
    }
}

Spring Security의 보안 구성 중에서 우리가 만들어 둔 커스텀 로그인 페이지를 사용하기 위한 최소한의 설정만 추가한 코드입니다.

코드 기능들

(1) CSRF(Cross-Site-Request Forgery) 공격에 대한 Spring Security에 대한 설정을 비활성화 한다.

Spring Security는 기본적으로 아무 설정 안하면 csrf() 공격을 방지하기 위해 클라이언트로부터 CSRF Token을 수신 후 검증함.

만약, csrf().disable() 설정을 하지 않는다면 403 에러로 인해 정상적인 접속이 불가능합니다.

(2) formLogin()을 통해 기본적인 인증 방법을 폼 로그인 방식으로 지정합니다.

(3) loginPage("/auths/login-form")메서드를 통해 우리가 템플릿 프로젝트에서 미리 만들어 둔 커스텀 로그인 페이지를 사용하도록 설정합니다.

여기서 "/auths/login-form"은 AuthController의 loginForm()핸들러 메서드에 요청을 전송하는 요청 URL입니다.

(4) loginProcessingUrl("/process_login")메서드를 통해 로그인 인증 요청을 수행할 요청 URL을 지정합니다.

  • "/process_login"은 우리가 만들어 둔 login.html에서 form 태그의 action 속성에 지정한 URL과 동일합니다.

(5) failureUrl("/auths/login-form?error")메서드를 통해 로그인 인증에 실패할 경우 어떤 화면으로 리다이렉트 할 것인가를 지정합니다.

로그인 실패 할경우 failureUrl()의 파라미너로 커스터 로그인 페이지 URL인 "/auths/login-form?error"을 보여준다.

(6) and() 메소드로 Spring Security 보안 설정을 메소드 체인 형태로 구성할 수 있다.

7), (8), (9)를 통해서 클라이언트의 요청에 대해 접근 권한을 확인합니다. 접근을 허용할지 여부를 결정합니다.

  • authorizeHttpRequests() 는 클라이언트의 요청이 들어오면 접근 권한을 확인 하겠다고 정의
  • anyRequest(),permitAll() 는 클라이언트의 모든 요청에 대해 접근을 허용한다.

request URI에 접근 권한 부여

사용자에게 부여된 Role을 이용해서 샘플 애플리케이션의 request URI에 접근 권한을 부여

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .formLogin()
            .loginPage("/auths/login-form")
            .loginProcessingUrl("/process_login")
            .failureUrl("/auths/login-form?error")
            .and()
            .exceptionHandling().accessDeniedPage("/auths/access-denied")   // (1)
            .and()
            .authorizeHttpRequests(authorize -> authorize                  // (2)
                    .antMatchers("/orders/**").hasRole("ADMIN")        // (2-1)
                    .antMatchers("/members/my-page").hasRole("USER")   // (2-2)
                    .antMatchers("⁄**").permitAll()                    // (2-3)
            );
        return http.build();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("kevin@gmail.com")
                        .password("1111")
                        .roles("USER")
                        .build();
        return new InMemoryUserDetailsManager(user);
    }
}

위에 코드에서 .authorizeHttpRequests().anyRequest().permitAll(); 설정을 통해 로그인 인증에 성공했을때 모든 화면에 접근 가능 했던 부분을 이번 코드로 사용자의 Role 별로 request URI에 접근 권한이 부여 되도록 수정.

(1) 권한이 없는 사용자가 특정 request URI에 접근 할 경우 발생하는 403 에러를 처리하는 페이지를 설정하는 코드다.

exceptionHandling() 메서드는 메서드의 이름 그대로 Exception을 처리하는 기능한다.

리턴하는 ExceptionHandlingConfigurer객체를 통해 구체적인 Exception 처리를 할 수 있습니다.

accessDeniedPage()메서드는 403 에러 발생 시, 파라미터로 지정한 URL로 리다이렉트 되도록 해줍니다.

(2) authorizeHttpRequests()메서드는 람다 표현식을 통해 request URI에 대한 접근 권한을 부여할 수 있다.

  • antMatchers() 메소드는 ant라는 빌드 툴에서 사용되는 Path Pattern을 이용해 매치되는 URL을 표현
  • (2-1) : ADMIN Role을 부여 받은 사용자만 /orders로 시작하는 모든 URL에 접근할수 있다는 의미다.
  • /orders/에서 **은 /orders로 시작하는 모든 하위 URL을 포함임.
  • /orders/*라는 URL을 지정했다면 /orders/1
    과 같이 /orders의 하위 URL의 depth가 1인 URL만 포함합니다.
  • (2-2) USER ROLE을 부여 받은 사람만 /members/my-page URL에 접근 할수 있음.
  • (2-3) 앞에서 지정한 URL 이외의 나머지 모든 URL은 Role에 상관없이 접근이 가능함을 의미합니다.

관리자 권한을 가진 사용자 정보 추가

ADMIN Role을 가진 사용자 추가

@Configuration
public class SecurityConfiguration {
    ...
    ...

    @Bean
    public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("kevin@gmail.com")
                        .password("1111")
                        .roles("USER")
                        .build();

        // (1)
        UserDetails admin =
                User.withDefaultPasswordEncoder()
                        .username("admin@gmail.com")
                        .password("2222")
                        .roles("ADMIN")
                        .build();

        return new InMemoryUserDetailsManager(user, admin);
    }
}

1)과 같이 admin@gmail.com이라는 InMemory User 하나를 더 추가하였으며, admin@gmail.com에게는 ADMINRole이 부여되었습니다.

로그 아웃 기능 사용하기 위한 설정

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .formLogin()
            .loginPage("/auths/login-form")
            .loginProcessingUrl("/process_login")
            .failureUrl("/auths/login-form?error")
            .and()
            .logout()                        // (1)
            .logoutUrl("/logout")            // (2)
            .logoutSuccessUrl("/")  // (3)
            .and()
            .exceptionHandling().accessDeniedPage("/auths/access-denied")
            .and()
            .authorizeHttpRequests(authorize -> authorize
                    .antMatchers("/orders/**").hasRole("ADMIN")
                    .antMatchers("/members/my-page").hasRole("USER")
                    .antMatchers("⁄**").permitAll()
            );
        return http.build();
    }
    
    ...
    ...
}
  • 로그 아웃에 대한 추가 설정을 위해 (1)과 같이 logout()을 먼저 호출해야한다. →logout()메소드는 로그아웃 설정을 위한 LogoutConfigurer를 리턴함
  • (2)에서 logoutUrl(”/logout”)을 통해 사용자가 로그아웃을 수행하기 위한 request URL을 지정함.
    • header.html의 로그아웃 메뉴에 지정한 href=”/logout”과 동일해야 합니다.
  • (3)에서는 로그아웃을 성공적으로 수행한 이후 리다이렉트 할 URL을 지정합니다.
    • 여기선 로그아웃 이후 샘플 애플리케이션의 메인 화면으로 리다이렉트하도록 지정했음.


회원가입 기능 구현

데이터베이스 연동 없는 로그인 인증

InMemory User를 사용하는 방식은 테스트 환경이나 데모 환경에서 사용할 수 있는 방법입니다.

회원 가입 폼을 통한 InMemory User 등록

회원 가입 폼을 통해 InMemory User를 등록하기 위한 작업 순서는 다음과 같습니다.

  • PasswordEncoder Bean 등록
  • MemberService Bean 등록을 위한 JavaConfiguration 구성
  • InMemoryMemberService 클래스 구현

1. PasswordEncoder Bean 등록

PasswordEncoder는 Spring Security에서 제공하는 패스워드 암호화 기능을 제공하는 컴포넌트이다.

  • 암호화 되지 않은 패스워드는 플레인 텍스트라고 부름

그래서 회원 가입 폼에서 전달받은 패스워드는 InMemory User로 등록하기 전에 암호화 되어야한다.

  • PasswordEncoder는 다양한 암호화 방식을 제공하며, Spring Security에서 지원하는 PasswordEncoder의 디폴트 암호화 알고리즘은 bcrypt 입니다.

PasswordEncoder Bean 등록

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;

@Configuration
public class SecurityConfiguration {
    ...
    ...

    @Bean
    public UserDetailsManager userDetailsService() {
        UserDetails user =
                User.withDefaultPasswordEncoder()
                        .username("kevin@gmail.com")
                        .password("1111")
                        .roles("USER")
                        .build();

        UserDetails admin =
                User.withDefaultPasswordEncoder()
                        .username("admin@gmail.com")
                        .password("2222")
                        .roles("ADMIN")
                        .build();

        return new InMemoryUserDetailsManager(user, admin);
    }

    // (1) PasswordEncoder을 Bean으로 등록
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();  // (1-1)
    }
}

(1-1)의 PasswordEncoderFactories.createDelegatingPasswordEncoder();를 통해 DelegatingPasswordEncoder를 먼저 생성하는데, 이 DelegatingPasswordEncoder가 실질적으로 PasswordEncoder구현 객체를 생성해 준다.

→우리가 userDetailsService() 메서드에서 미리 생성하는 InMemoryUser의 패스워드는 내부적으로 디폴트 PasswordEncoder를 통해 암호화된다!!!

2. MemberService Bean 등록을 위한 JavaConfiguration 구성

  1. InMemory User 등록을 위한 InMemoryMemberService 클래스

    InMemory User를 등록하기 위한 MemberService 인터페이스 구현 클래스인 클래스다.

    package com.codestates.member;
    
    public class InMemoryMemberService implements MemberService {
        public Member createMember(Member member) {
    
            return null;
        }
    }
  2. 데이터베이스에 User를 등록하기 위한 DBMemberService 클래스

    데이터베이스에 User를 등록하기 위한 MemberService 인터페이스의 구현 클래스인 DBMemberService클래스이다.

  3. JavaConfiguration 구성

    JavaConfiguration 클래스에서는 MemberService 인터페이스의 구현 클래스인 InMemoryMemberService를 Spring Bean으로 등록한다.

    import com.codestates.member.InMemoryMemberService;
    import com.codestates.member.MemberService;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.security.provisioning.UserDetailsManager;
    
    @Configuration
    public class JavaConfiguration {
        // (1)
        @Bean
        public MemberService inMemoryMemberService(UserDetailsManager userDetailsManager, 
                                                   PasswordEncoder passwordEncoder) {
            return new InMemoryMemberService(userDetailsManager, passwordEncoder);
        }
    }

    (1)은 InMemoryMemberService Bean 객체를 생성함.

    데이터 베이스 연동 없이 메모리에 Spring Secudiry의 User를 등록해야 해서 UserDetailsManager 객체가 필요하다.

    그리고 User 등록시 , 패스워드를 암호화한 후에 등록해야 해서 Spring Security에서 제공하는 PasswordEncoder객체가 필요하다.

    그래서 이 두 객체를 InMemoryMemberService 객체 생성 시, DI 해 준다.

InMemoryMemberService 구현

회원 가입 정보를 전달받아 Spring Security의 User를 메모리에 등록해 주는 InMemoryMemberService 클래스의 코드이다.

package com.codestates.member;

import com.codestates.auth.utils.AuthorityUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.UserDetailsManager;

import java.util.List;

public class InMemoryMemberService implements MemberService {  // (1)
    private final UserDetailsManager userDetailsManager;
    private final PasswordEncoder passwordEncoder;

    // (2)
    public InMemoryMemberService(UserDetailsManager userDetailsManager, PasswordEncoder passwordEncoder) {
        this.userDetailsManager = userDetailsManager;
        this.passwordEncoder = passwordEncoder;
    }

    public Member createMember(Member member) {
        // (3) 권한 목록 생성 및 권한 지정
        List<GrantedAuthority> authorities = createAuthorities(Member.MemberRole.ROLE_USER.name());

        // (4) User 패스워드 암호화
        String encryptedPassword = passwordEncoder.encode(member.getPassword());

        // (5)User 등록
        UserDetails userDetails = new User(member.getEmail(), encryptedPassword, authorities);

        // (6)
        userDetailsManager.createUser(userDetails);

        return member;
    }

    private List<GrantedAuthority> createAuthorities(String... roles) {
        // (3-1)
        return Arrays.stream(roles)
                .map(role -> new SimpleGrantedAuthority(role))
                .collect(Collectors.toList());
    }
}
  • InMemoryMemberService 클래스는 MemberService 인터페이스를 구현하는 구현 클래스임으로 (1)과 같이 implements MemberService를 지정합니다. 우리가 여태껏 @Service 애너테이션을 사용해 특정 서비스 클래스를 Bean으로 등록하는 방법을 사용해 왔지만 여기서는 @Service을 사용하지 않고, JavaConfiguration을 이용해 Bean을 등록하고 있다는 사실을 기억하기 위해 이렇게 한다.

(2) UserDetailsManager,PasswordEndcoder를 DI받음

  • UserDetailsManager는 Spring Security의 User를 관리하는 관리자 역할을 한다. 우리가 SecurityConfiguration에서 Bean으로 등록한 UserDetailsManager는 InMemoryUserDetailsManager이므로 여기서 DI 받은 UserDetailsManager 인터페이스의 하위 타입은InMemoryUserDetailsManager 라는 사실을 기억 해야한다.
  • PasswordEncoder는 Spring Security User를 등록할 때 패스워드를 암호화 해주는 클래스다. InMemory User도 패스워드의 암호화가 필수이다. 그래서 DI 받은 PasswordEncoder를 이용해 User 패스워드를 암호화 해줘야한다.

(3) Spring Security에서 User 를 등록 할려면 해당 User의 권한을 지정해줘야한다.

그래서 createAuthorities(Member.MemberRole.ROLE_USER.name());를 해서 User 권한 목록을 List로 생성했다.

Member 클래스에는 MemberRole 이라는 enum이 정의 되어 있고, ROLE_USERROLE_ADMIN이라는 enum 타입이 정의되어 있다.

  • Spring Security에서는 SimpleGrantedAuthorit 를 사용해 Role 베이스 형태의 권한을 지정할 때 ‘ROLE_’ + 권한 명형태로 지정해 주어야 한다. 안하면 적절한 권한 매핑이 이루어지지 않는다.

(3-1) 에서 Java Stream API로 생성자 파라미터로 해당 USer Role을 전달 해 SimpleGrantedAuthority 객체를 생성해 List 형태로 리턴한다.

(4) PasswordEncoder를 이용해 등록할 User의 패스워드를 암호화한다.

만약 암호화 안하고 User 등록 한다면 User등록은 되는데 로그인 인증시

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null” 이 에러가 뜬다. 그래서 무조건 패스워드를 암호화 해야한다.

(5) User 등록하기 위해 UserDetails를 생성함.

  • Spring Security에서는 Spring Security에서 관리하는 User 정보를 UserDetails로 관리한다

(6) UserDetailsManager의 createUser() 메소드를 이용해 User를 등록한다.

데이터베이스 연동을 통한 로그인 인증

Member 엔티티 클래스로 회원 인증 정보를 포함해 회원 정보를 데이터베이스 테이블에서 관리해보자

Custom UserDetailsService를 사용하는 방법

Spring Security에서는 User 인증 정보를 테이블에 저장하고, 테이블에 저장된 인증 정보를 이용해 인증 프로세스를 진행할 수 있는 방법이 여러가지 있는데

그중 하나가 Custom UserDetailsService를 이용하는 방법이다.

  • 일반 적으로 인증을 시도하는 주체를 user라고 부르고, Principal은 User의 구체적인 정보를 의미하고 일반적으로 Spring Security에서 Username이라고 의미한다.
  • Member 엔티티 클래스가 로그인 인증 정보를 포함할 텐데 이 Member 엔티티가 Spring Security의 User 정보를 포함한다고 보면 된다.

1. SecurityConfiguration의 설정 변경 및 추가

package com.codestates.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .headers().frameOptions().sameOrigin() // (1) 이건 H2 사용하기 위한 설정이다.
            .and()
            .csrf().disable()
            .formLogin()
            .loginPage("/auths/login-form")
            .loginProcessingUrl("/process_login")
            .failureUrl("/auths/login-form?error")
            .and()
            .logout()
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
            .and()
            .exceptionHandling().accessDeniedPage("/auths/access-denied")
            .and()
            .authorizeHttpRequests(authorize -> authorize
                    .antMatchers("/orders/**").hasRole("ADMIN")
                    .antMatchers("/members/my-page").hasRole("USER")
                    .antMatchers("⁄**").permitAll()
            );
        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

(1) H2사용하기 위한 설정이다.

여기서 frameOptions()는 HTML 태그 중에서 <frame>이나 <iframe>, <object>태그에서 페이지를 렌더링할지의 여부를 결정하는 기능을 합니다.

  • Spring Security에서는 Clickjacking 공격을 막기 위해 기본적으로 frameOptions() 기능이 활성화 되어 있고 디폴트 값은 DENY 이다. 이 뜻은 HTML 태그를 이용한 페이지 렌더링을 허용하지 않겠다는 의미다.

.frameOptions().sameOrigin()을 호출하면 동일 출처로부터 들어오는 request만 페이지 렌더링을 허용한다.

2. JavaConfigurtaion의 Bean 등록 변경

@Configuration
public class JavaConfiguration {
    // (1)
    @Bean
    public MemberService dbMemberService(MemberRepository memberRepository,
                                         PasswordEncoder passwordEncoder) {
        return new DBMemberService(memberRepository, passwordEncoder); (1-1)
    }
}

(1)과 같이 데이터베이스에 User의 정보를 저장하기 위해 MemberService 인터페이스의 구현 클래스를 DBMemberService로 변경합니다.

  • DBMemberService는 내부에서 데이터를 데이터베이스에 저장하고, 패스워드를 암호화해야 하므로 (1-1)과 같이 MemberRepositoryPasswordEncoder객체를 DI 해줍니다.

3. DMMemberService 구현

DMMemberSerive는 User의 인증 정보를 데이터베이스에 저장하는 역할한다.

  • User라고 부르는 정보는 우리가 회원가입시 등록하는 회원 정보 안에 포함 되어 있다고 본다.

→ 이 말은 이 Member 엔티티 클래스의 필드에 인증 정보를 담는 password 필드가 포함 된다고 생각하면 된다.

import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Transactional
public class DBMemberService implements MemberService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    // (1) DI를 받는다.
    public DBMemberService(MemberRepository memberRepository,
                             PasswordEncoder passwordEncoder) {
        this.memberRepository = memberRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public Member createMember(Member member) {
        verifyExistsEmail(member.getEmail());
        String encryptedPassword = passwordEncoder.encode(member.getPassword());  // (2)
        member.setPassword(encryptedPassword);    // (3)

        Member savedMember = memberRepository.save(member);

        System.out.println("# Create Member in DB");
        return savedMember;
    }
    
    ...
    ...
}
  • (2)에서 PasswordEncoder를 이용해 패스워드를 암호화합니다.
  • (3)에서 암호화된 패스워드를 password 필드에 다시 할당합니다.

무조건 패스워드는 암호화 된 상태에서 복호화 할 이유가 없기땜누에 단방향 암호화 방식으로 암호화 되어야한다.

4. Custom UserDetailsService 구현

데이터베이스에서 조회한 User 인증정보를 기반으로 인증 처리 한다.

  • UserDetailService는 Spring Security에서 제공하는 컴포넌트 이며 User 정보를 로드 하는 핵심 인터페이스이다.
  • 로드(load)는 인증에 필요한 User 정보를 어딘가에서 가지고 온다는 의미이다.
  • 어딘가 : 메모리,DB등이 될수도 있음.

기억 해야할것

우리가 InMemory User를 등록하는 데 사용했던 InMemoryUserDetailsManagerUserDetailsManager인터페이스의 구현체

UserDetailsManagerUserDetailsService를 상속하는 확장 인터페이스

HelloUserDetailsService

데이터베이스에서 조회한 인증 정보를 기반으로 인증을 처리하는 Custom UserDetailsService인 HelloUserDetailsService클래스의 코드

package com.codestates.auth;

import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.Member;
import com.codestates.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
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.Component;

import java.util.Collection;
import java.util.Optional;

@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);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));

        // (4)
        Collection<? extends GrantedAuthority> authorities = authorityUtils.createAuthorities(findMember.getEmail());

        // (5)   
        return new User(findMember.getEmail(), findMember.getPassword(), authorities);
    }
}

일단 코드 설명은

  • HelloUserDetailsService와 같은 Custom UserDetailsService를 구현하기 위해서는 (1)과 같이 UserDetailsService인터페이스를 구현해야 한다.
  • HelloUserDetailsService은 DB에서 User를 조회하고 조회한 User 권한 정보를 생성하기 위해 (2)와 같이 MemberRepository와 HelloAuthorityUtils 클래스를 DI 받는다.
  • UserDetailsSerivce 인터페이스를 Implements하는 구현 클래스는 (3)와 같이 loadUserByUsername(String username)이란느 추상 메소드를 구현해야 한다.
  • (4)에서 HelloAuthorityUtils 를 이용해 DB에서 조회한 회원의 이메일 정보를 이용해 Role 기반의 권한 정보(GrantedAuthority) 컬렉션을 생성한다.
  • DB에서 조회한 인증 정보와 (4)에서 생성한 권한 정보를 Spring Security에서는 아직 알지 못해서 Spring Security에 이정보들을 제공해 주어야 하며,
  • (5)에서는 UserDetails 인터페이스의 구현체인 User 클래스의 객체를 통해 제공하고 있다.
  • DB에서 조회한 User 클래스의 객체를 리턴하면 Spring Security가 이정보를 이요해 인증 절차를 수행 한다.

→ DB에서 User의 인증 정보만 Spring Security에 넘겨 주고, 인증 처리는 Spirng Security가 대신 해준다.

  • UserDetails

—>UserDetailsService에 의해 로드되어 인증을 위해 사용되는 핵심 User 정보를 표현하는 인터페이스이다.

UserDetails인터페이스의 구현체는 Spring Security에서 보안 정보 제공을 목적으로 직접 사용되지는 않고, Authentication객체로 캡슐화 되어 제공 된다.

HelloAuthorityUtils

HelloUserDetailsService에서 Role 기반의 User 권한을 생성하기 위해 사용한 HelloAuthorityUtils 코드

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.stereotype.Component;
import java.util.List;

@Component
public class HelloAuthorityUtils {
    // (1)
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    // (2)
    private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");

    // (3)
    private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    
    public List<GrantedAuthority> createAuthorities(String email) {
        // (4)
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES;
        }
        return USER_ROLES;
    }
}

User 의 권한을 매핑, 생성하는 HelloAuthorityUtils

(1) application.yml에 추가한 프로퍼티를 가져오는 표현식이다.

@Value(”${프로퍼티 경로}”) 의 표현식 형태로 작성하면 application.yml에 정의되어 있는 프로퍼티의 값을 클래스 내에서 사용할 수 있다.

(1)에서 application.yml에 미리 정의한 관리자 권한을 가질 수 있는 이메일 주소를 불러오고 있다.

application.yml 파일에 정의한 관리자용 이멜 주소는 회원 등록 시, 특정 이메일 주소에 관리자 권한을 부여할 수 있는지를 결정하기 위해 사용된다.

application.yml파일에 이렇게 관리자 이메일 주소를 정의 해야함.

...
...

mail:
  address:
    admin: admin@gmail.com

(2)에서는 Spring Security에서 지원하는 AuthorityUtils 클래스를 이용해 관리자용 권한 목록을 List 객체로 미리 생성함

관리자 권한의 경우, 일반 사용자의 권한까지 추가로 포함되어 있다.

(3)에서는 Spring Security에서 지원하는 AuthorityUtils 클래스를 이용해 일반 사용 권한 목록을 List 객체로 미리 생성한다.

(4)에선 파라미터로 전달 받은 이메일 주소가 application.yml파일에서 나온 관리자용 이메일 주소와 동일하면 관리자용 권한인 ADMIN_ROLES를 리턴한다.

5. H2 웹 콘솔에서 등록한 회원 정보 확인 및 로그인 인증 테스트

회원 가입 메뉴에서 회원 등록하고 H2에서 확인하면

이렇게 등록이 된다.

PASSWORD는 역시 암호화가 되어 있고, 로그인 해보면 정상적으로 로그인이 된다.

6. Custom UserDetails 구현

여기서까지 DB에 회원 인증 정보를 저장하고, 저장된 인증 정보를 기반으로 로그인 인증 하는 데 문제는 없다.

여기서 조금더 깔끔하게 코드를 구성 하면

HelloUserDetailsService코드를

import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.Member;
import com.codestates.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
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.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserDetailsServiceV1 implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

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

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> optionalMember = memberRepository.findByEmail(username);
        Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
        Collection<? extends GrantedAuthority> authorities = authorityUtils.createAuthorities(findMember);

        // (1) 개선하면 좋은 포인트
        return new User(findMember.getEmail(), findMember.getPassword(), authorities);
    }
}

개선을 하면 이렇게 짠다.

import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.Member;
import com.codestates.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
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.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserDetailsServiceV2 implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    public HelloUserDetailsServiceV2(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

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

        return new HelloUserDetails(findMember);  // (1) 개선된 부분
    }

    // (2) HelloUserDetails 클래스 추가
    private final class HelloUserDetails extends Member implements UserDetails { // (2-1)
        // (2-2)
        HelloUserDetails(Member member) {
            setMemberId(member.getMemberId());
            setFullName(member.getFullName());
            setEmail(member.getEmail());
            setPassword(member.getPassword());
        }

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorityUtils.createAuthorities(this.getEmail());  // (2-3) 리팩토링 포인트
        }

        // (2-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;
        }
    }

}

기존v1버전은 loadUserByUsername() 메서드의 리턴 값으로 new User(findMember.getEmail(), findMember.getPassword(), authorities);을 리턴했지만,

v2에서는 new HelloUserDetails(findMember);으로 Custom UserDetails 클래스의 생성자로 findMember를 전달하면서 더 깔끔하게 나타냈다.

(2) HelloUserDetails 클래스는 UserDetails 인터페이스를 구현하고 있으며 Member 엔티티 클래스르 상속하고 있다

→ 이렇게 구성하면 DB에 조회한 회원 정보를 Spring Security의 User 정보로 변환하는 과정과 User 권한 정보를 생성하는 과정을 캡슐화 할수 있다.

Member 엔티티 클래스를 상속하고 있기에 HelloUserDetails를 리턴 받아 사용하는 측에서는 두개 클래스의 객체를 모두 다 손쉽게 캐스팅 해서 사용 가능하다느 장점이 있다.

(2-3) 에선 HelloAuthorityUtils의 createAuthorities() 메소드를 이용해 User 권한 정보를 생성하고 있다.

(2-4) username을 Member 클래스의 email 주소로 채우고 있다.getUsername()리턴 값은 null일 수 없습니다.

7.User Role을 DB에서 관리하기

Custom UserDetailsService를 이용한 로그인 인증의 마지막 단계이다.

User의 권한 정보를 데이터베이스에서 관리하기 위해서는 다음과 같은 과정이 필요한다.

  • User의 권한 정보를 저장하기 위한 테이블 생성

  • 회원 가입 시, User의 권한 정보(Role)를 데이터베이스에 저장하는 작업

  • 로그인 인증 시, User의 권한 정보를 데이터베이스에서 조회하는 작업

  • User의 권한 정보 테이블 생성

User의 권한 정보 테이블을 생성하기 전에 User와 User의 권한 정보 간에 관계를 먼저 생각해야 한다

관계?는 테이블간의 연관관계 → JPA로 연관 관계를 맺을 수 있다.

Spring Security의 User 역할을 하는 Member 엔티티 클래스에 User 권한 정보 매핑

import com.codestates.audit.Auditable;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;
import java.security.Principal;
import java.util.ArrayList;
import java.util.List;

@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member extends Auditable implements Principal{
    @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;

    // (1) User의 권한 정보 테이블과 매핑되는 정보
    @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;
    }

    @Override
    public String getName() {
        return getEmail();
    }

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

        @Getter
        private String status;

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

    public enum MemberRole {
        ROLE_USER,
        ROLE_ADMIN
    }
}

(1)과 같이 List, Set 같은 컬렉션 타입의 필드는 @ElementCollection애너테이션을 추가하면 User 권한 정보와 관련된 별도의 엔티티 클래스를 생성하지 않아도 간단하게 매핑 처리가 됩니다.

  • 회원 가입 시, User의 권한 정보를 DB에 저장

회원 가입 시, 해당 회원의 권한 정보를 MEMBER_ROLES 테이블에 저장해보자

DBMemberService

회원 등록 시, 권한 정보를 DB에 저장

import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Transactional
public class DBMemberService implements MemberService {
    ...
    ...
  
    private final HelloAuthorityUtils authorityUtils;

    ...
    ...

    public Member createMember(Member member) {
        verifyExistsEmail(member.getEmail());
        String encryptedPassword = passwordEncoder.encode(member.getPassword());
        member.setPassword(encryptedPassword);

        // (1) Role을 DB에 저장
        List<String> roles = authorityUtils.createRoles(member.getEmail());
        member.setRoles(roles);

        Member savedMember = memberRepository.save(member);

        return savedMember;
    }

    ...
    ...
}

DBMemberService에서 회원 등록 시, 회원의 권한 정보를 데이터베이스에 저장하는 코드가 추가한다.

(1)에서 authorityUtils.createRoles(member.getEmail());를 통해 회원의 권한 정보(List<String> roles)를 생성한 뒤 member 객체에 넘겨준다.

HelloAuthorityUtils클래스의 코드

회원의 Role 정보를 생성하는 createRoles() 메소드 추가함

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
public class HelloAuthorityUtils {
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    ...
    ...

    private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
    private final List<String> USER_ROLES_STRING = List.of("USER");

    ...
    ...

    // (1) DB 저장용
    public List<String> createRoles(String email) {
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES_STRING;
        }
        return USER_ROLES_STRING;
    }
}

(1)에서 파라미터로 전달된 이메일 주소가 application.yml 파일의 mail.address.admin 프로퍼티에 정의된 이메일 주소와 동일하면 관리자 Role 목록 ADMIN_ROLES_STRING 리턴하고, 아니면 일반사용자 ROLE 목록을 리턴한다.

  • 로그인 인증 시, User의 권한 정보를 데이터베이스에서 조회하는 작업

마지막 작업

로그인 인증에 성공 시, 제공하는 User 권한 정보를 DB 테이블에서 관리되는 Role을 기반으로 생성한다.

개선된 HelloUserDetailsServiceV3

DB에서 조회한 Role을 기반으로 User의 권한 정보 생성

package com.codestates.auth;

import com.codestates.auth.utils.HelloAuthorityUtils;
import com.codestates.exception.BusinessLogicException;
import com.codestates.exception.ExceptionCode;
import com.codestates.member.Member;
import com.codestates.member.MemberRepository;
import org.springframework.security.core.GrantedAuthority;
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.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserDetailsServiceV3 implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final HelloAuthorityUtils authorityUtils;

    public HelloUserDetailsServiceV3(MemberRepository memberRepository, HelloAuthorityUtils authorityUtils) {
        this.memberRepository = memberRepository;
        this.authorityUtils = authorityUtils;
    }

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

        return new HelloUserDetails(findMember);
    }

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

        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            // (2) DB에 저장된 Role 정보로 User 권한 목록 생성
            return authorityUtils.createAuthorities(this.getRoles());
        }

        ...
        ...
    }

}

데이터베이스의 MEMBER_ROLES 테이블에서 조회한 Role을 기반으로 User의 권한 목록(List<GrantedAuthority>)을 생성하는 로직이 추가된 HelloUserDetailsService 클래스이다.

  • (1)에서는 HelloUserDetails가 상속하고 있는 Member에 DB에서 조회한 List roles를 전달한다.
  • • (2)에서 다시 Member(extends Member)에 전달한 Role 정보를 authorityUtils.createAuthorities() 메서드의 파라미터로 전달해서 권한 목록(List<GrantedAuthority>)을 생성한다.

참고로

데이터베이스에서 Role 정보를 가지고 오지 않았을 때는 authorityUtils.createAuthorities(this.getRoles());아니라 authorityUtils.createAuthorities(this.getEmail());이였다는것

HelloAuthorityUtils

데이터베이스에서 조회한 Role 정보를 기반으로 User의 권한 목록 생성하는 createAuthorities(List<String> roles)메서드가 추가된 HelloAuthorityUtils클래스

데이터베이스에서 조회한 Role 정보를 기반으로 User의 권한 목록 생성

package com.codestates.auth.utils;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
public class HelloAuthorityUtils {
    @Value("${mail.address.admin}")
    private String adminMailAddress;

    private final List<GrantedAuthority> ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER");
    private final List<GrantedAuthority> USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER");
    private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "USER");
    private final List<String> USER_ROLES_STRING = List.of("USER");

    // 메모리 상의 Role을 기반으로 권한 정보 생성.
    public List<GrantedAuthority> createAuthorities(String email) {
        if (email.equals(adminMailAddress)) {
            return ADMIN_ROLES;
        }
        return USER_ROLES;
    }

    // (1) DB에 저장된 Role을 기반으로 권한 정보 생성
    public List<GrantedAuthority> createAuthorities(List<String> roles) {
       List<GrantedAuthority> authorities = roles.stream()
               .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // (2)
               .collect(Collectors.toList());
       return authorities;
    }

    ...
    ...
}

단순히 데이터베이스에서 가지고 온 Role 목록(List<String> roles)을 그대로 이용해서 권한 목록(authorities)을 만들면 된다.

(2)와 같이 SimpleGrantedAuthority객체를 생성할 때 생성자 파라미터로 넘겨주는 값이 “ USER
" 또는 “ADMIN"으로 넘겨주면 안 되고 “ROLE_USER" 또는 “ROLE_ADMIN" 형태로 넘겨주어야 한다

Custom AuthenticationProvider를 사용하는 방법

이 앞에는 Custom AuthenticationProvider을 사용해서 Spring Security가 내부적으로 인증을 대신 처리 해주는 방식인데,

이번에는 Custom AuthenticationProvider을 이용해 우리가 직접 로그인 인증 처리하는 방법이다.

HelloUserAuthenticationProvider(V1)

Custom AuthenticationProvider인 HelloUserAuthenticationProvider

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Optional;

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

    public HelloUserAuthenticationProvider(HelloUserDetailsService userDetailsService, 
                                           PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

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

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

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

        String password = userDetails.getPassword();
        verifyCredentials(authToken.getCredentials(), password);    // (3-4)

        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();  // (3-5)

        // (3-6)
        return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
    }

    // (2) HelloUserAuthenticationProvider가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려준다.
    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }

    private void verifyCredentials(Object credentials, String password) {
        if (!passwordEncoder.matches((String)credentials, password)) {
            throw new BadCredentialsException("Invalid User name or User Password");
        }
    }
}

AuthenticationProvider 인터페이스의 구현 클래스로 정의한다.

(1)과 같이 AuthenticationProvider 인터페이스의 구현 클래스로 정의한다.

AuthenticationProvider를 구현한 구현 클래스가 Spring Bean으로 등록되어 있다면 해당 AuthenticationProvider를 이용해서 인증을 진행한다.

클라이언트 쪽에서 로그인 인증을 시도하면 우리가 구현한 HelloUserAuthenticationProvider가 직접 인증을 처리하게 됩니다.

  • AuthenticationProvider 인터페이스의 구현 클래스는 authenticate(Authentication authentication) 메서드와 supports(Class<?> authentication) 메서드를 구현해야 합니다. 그중에서 (2)의 supports(Class<?> authentication) 메서드는 우리가 구현하는 Custom AuthenticationProvider(HelloUserAuthenticationProvider)가 Username/Password 방식의 인증을 지원한다는 것을 Spring Security에 알려주는 역할을 합니다. supports() 메서드의 리턴값이 true일 경우, Spring Security는 해당 AuthenticationProvider의 authenticate() 메서드를 호출해서 인증을 진행합니다.
  • (3) 의 authenticate(Authentication authentication)에서 우리가 직접 작성한 인증 처리 로직을 이용해 사용자의 인증 여부를 결정한다.
    • 자세한 내용
      • (3-1)에서 authentication을 캐스팅하여 UsernamePasswordAuthenticationToken을 얻는다.
      • 이 UsernamePasswordAuthenticationToken 객체에서 (3-2)와 같이 해당 사용자의 Username을 얻은 후 존재하는지 체크한다.
      • Username이 존재한다면 (3-3)과 같이 userDetailsService를 이용해 DB에서 해당 사용자를 조회한다.
      • (3-4)에서 로그인 정보에 포함된 패스워드(authToken.getCredentials())와 데이터베이스에 저장된 사용자의 패스워드 정보가 일치하는지 검증 한다.
      • (3-4)의 검증 과정을 통과 했다면 로그인 인증에 성공한 사용자이므로 (3-5)와 같이 해당 사용자의 권한을 생성한다.
      • (3-4)의 검증 과정을 통과했다면 로그인 인증에 성공한 사용자 이므로 (3-5)와 같이 해당 사용자의 권한을 생성한다.
      • (3-6)와 같이 인증된 사용자의 인증 정보를 리턴값으로 전달한다.

만약 회원 가입을 하지 않고 로그인을 시도할 경우(회원 가입 이후에는 상관없습니다)인증에 실패하고, 이런 화면을 만난다.

HelloAuthenticationProvider을 통한 인증 실패 시 화면

1번 그림

우리가 앞에서 HelloUserDetailsService 를 이용해 인증을 처리할 경우에는 인증 실패 시, Spring Security 내부에서 인증 실패에 대한 전용 Exception인 AuthenticationException 을 throw 하게 되고 이 AuthenticationException 이 throw 되면 결과적으로 SecurityConfiguration에서 설정한 .failureUrl("/auths/login-form?error") 을 통해 로그인 폼으로 리다이렉트 하면서 아래와 같이 “로그인 인증에 실패했습니다.”라는 인증 실패 메시지를 표시한다.

2번 그림

HelloUserDetailsService 를 이용해 인증 처리 시, 인증 실패 화면

Custom AuthenticationProvider를 이용할 경우에는 회원가입 전 인증 실패 시 2번 그림 같은 화면이 표시되지 않고 1번 그림과 같은 “Whitelebel Error Page”가 표시되는 걸까요?

MemberService에서 등록된 회원 정보가 없으면, BusinessLogicException을 throw 하는데 이 BusinessLogicException이 Cusotm AuthenticationProvider를 거쳐 그대로 Spring Security 내부 영역으로 throw 되기 때문이다.

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

결국 서블릿 컨테이너 영역에서 해당 Exception에 대해 “/error” URL로 포워딩하는데 우리가 특별히 “/error” URL로 포워딩 되었을 때 보여줄 뷰 페이지를 별도로 구성하지 않았기 때문에 디폴트 페이지인 “Whitelebel Error Page”를 브라우저에 표시하는 것이다.

해결책은 Custom AuthenticationProvicer에서 Exception이 발생할 경우, 이 Exception을 catch해서 AuthenticationException으로 rethrow를 해주면 됩니다.

개선된 HelloUserAuthenticationProvider(V2)

AuthenticationException이 아닌 다른 Exception이 발생할 경우 AuthenticationException
으로 다시 rethrow 하도록 개선된 HelloUserAuthenticationProvider 코드

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Optional;

@Component
public class HelloUserAuthenticationProvider implements AuthenticationProvider {
    private final HelloUserDetailsServiceV3 userDetailsService;
    private final PasswordEncoder passwordEncoder;

    public HelloUserAuthenticationProvider(HelloUserDetailsServiceV3 userDetailsService,
                                           PasswordEncoder passwordEncoder) {
        this.userDetailsService = userDetailsService;
        this.passwordEncoder = passwordEncoder;
    }

    // V2: AuthenticationException을 rethrow 하는 개선 코드
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;

        String username = authToken.getName();
        Optional.ofNullable(username).orElseThrow(() -> new UsernameNotFoundException("Invalid User name or User Password"));
        try {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            String password = userDetails.getPassword();
            verifyCredentials(authToken.getCredentials(), password);

            Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
            return UsernamePasswordAuthenticationToken.authenticated(username, password, authorities);
        } catch (Exception ex) {
            throw new UsernameNotFoundException(ex.getMessage()); // (1) AuthenticationException으로 다시 throw 한다.
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.equals(authentication);
    }

    private void verifyCredentials(Object credentials, String password) {
        if (!passwordEncoder.matches((String)credentials, password)) {
            throw new BadCredentialsException("Invalid User name or User Password");
        }
    }
}

(1)에서 UsernameNotFoundException을 throw 하도록 수정되었는데, UsernameNotFoundException
AuthenticationException을 상속하는 하위 Exception이기 때문에 이 UsernameNotFoundException
이 throw되면 Spring Security 쪽에서 정상적으로 catch해서 그림2번 같이 정상적인 인증 실패 화면으로 리다이렉트 시켜준다.

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

  • AuthenticationProvider

profile
씨앗

0개의 댓글