개발/Spring Security(Hodol)

Spring - Spring Security (기본 설정, 로그인 폼 커스텀, UserDetailService, 역할 및 권한, remeberMe(자동 로그인))

잇(IT) 2023. 10. 10. 15:58
728x90
- Spring Security

- Spring Security는 Spring Framework 기반의 애플리케이션에서 보안과 관련된 기능을 처리하는 데 사용되는 강력한 보안 프레임워크이다.

- Spring Security를 사용하면 애플리케이션의 인증(Authentication) 및 권한 부여(Authorization) 관련 기능을 구현하고 관리할 수 있다.

1. 인증(Authentication)

 1.1. 사용자가 누구인지 확인하기 위한 인증 메커니즘을 제공한다. 

 1.2. 주요 인증 방식으로는 폼 인증, HTTP 기본 인증, OAuth, LDAP, SSO(Single Sign-On) 등이 있다.

 1.3. 사용자 인증 정보는 사용자 이름/암호, 토큰, 인증 서버 등을 통해 확인된다.

2. 권한 부여(Authorization)

 2.1. 사용자에 대한 접근 권한을 관리하고, 특정 리소스에 대한 접근을 허용하거나 거부할 수 있는 권한 부여 메커니즘을 제공한다.

 2.2. 이를 통해 개발자는 세밀한 접근 제어 및 권한 부여를 설정할 수 있다.

3. 세션 관리(Session Management)

 3.1. 사용자 세션 관리를 지원하여 로그인 세션 관리, 세션 공유, 세션 무효화, 세션 타임아웃 등을 처리할 수 있다.

4. CSRF(Cross-Site Request Forgery) 방어

 4.1. CSRF 공격을 방어하기 위한 내장된 보호 매커니즘을 제공한다.

5. CORS(Cross-Origin Resource Sharing) 설정

 5.1. Spring Security를 사용하여 웹 애플리케이션에서 CORS 정책을 구성하고 제어할 수 있다.

6. 사용자 정의 필터 및 보안 이벤트 처리

 6.1. Spring Security를 확장하여 사용자 정의 필터를 생성하거나 보안 이벤트를 처리할 수 있다.

7. 다양한 인증 제공자(Authentication Providers)

 7.1. Spring Security는 다양한 인증 제공자를 지원하여 데이터베이스, 메모리, LDAP, OAuth, OpenID, SAML, JWT 등 다양한 인증 방식을 사용할 수 있다.


- Spring Security 설정

- build.gradle

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'

1. build.gradle에 Spring Security를 사용하기 위한 의존성을 추가해준다.


- 웹 애플리케이션을 실행 한 뒤 기본 경로인 localhost:8080/ 주소로 요청을 보내게 되면 다음과 같이 로그인 페이지로 이동한다.

1. 위 /login 페이지는 Spring Security가 기본으로 제공하는 로그인 페이지이다.

2. 웹 애플리케이션 실행 후 웹 애플리케이션 실행 로그를 보게 되면 초기 Security Password를 확인할 수 있다.

3. 또한 Spring Security 의존성을 추가한 뒤, DefaultSecurityFilterChain을 같이 실행하는 것을 확인 할 수 있다.

 3.1. Filter Chain에 의해 보안에 대한 검증을 이루는 것을 짐작할 수 있다.

* Spring Security의 기본 제공하는 ID는 user이다.

4. Spring Security에서 제공하는 ID와 PASSWORD를 입력하게 되면 기존에 생성한 웹 페이지로 이동하는 것을 확인 할 수 있다.

5. Network HTTP 통신을 확인해보면 Response Header에 Set-Cookie를 통해 Session ID를 전달한 것을 확인할 수 있다.

6. 이는 로그인이 확인된 사용자에 대해 Cookie를 통해 SessionID를 전송하여 해당 사용자에 대한 웹 애플리케이션 접근을 허용하는 것이다.

7. Cookie를 강제로 삭제 한 뒤 페이지를 새로고침 하거나 웹 애플리케이션의 Controller로 요청을 보내게 되면 Cookie에 의한 사용자 인증 정보가 없기 때문에 로인 페이지로 다시 이동하게 된다.


- Spring Security 로그인 폼 커스텀 설정

- SecurityConfig.java

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return new WebSecurityCustomizer() {
            @Override
            public void customize(WebSecurity web) {
                web.ignoring()
                        .requestMatchers("/favicon.ico")
                        .requestMatchers("/error")
                        .requestMatchers(toH2Console());
            }
        };
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        return http
                .authorizeHttpRequests()
//                    .requestMatchers(HttpMethod.POST,"/auth/login").permitAll()
//                    .requestMatchers(HttpMethod.POST,"/auth/signup").permitAll()
                //위의 방법을 사용하면 무한 페이지 리다이렉트 될 수 있다...?
                    .requestMatchers("/auth/login").permitAll()
                    .requestMatchers("/auth/signup").permitAll()
//                .requestMatchers("/user").hasRole("USER")
                .requestMatchers("/user").hasAnyRole("USER", "ADMIN")
                // 관리자는 사용자 페이지도 접근 가능해야 하기 때문에 hasAnyRole을 통해 여러 권한을 줄 수 있다.
//                .requestMatchers("/admin").hasRole("ADMIN")
                //이건 일반적인 경우 사용
                .requestMatchers("/admin").access(new WebExpressionAuthorizationManager("hasRole('ADMIN') AND hasAuthority('WRITE')"))
                //역할과 권한을 둘 다 조건으로 줘야 할 때
                //hasRole과 hasAuthority가 있다.
                //여기서는 ROLE를 붙일 필요는 굳이 없다.
                    //애는 권한 없이도 허용
                    .anyRequest().authenticated()
                    //나머지는 인증해
                .and()
                .formLogin()
                    .loginPage("/auth/login")
                    //로그인 페이지 주소
                    .loginProcessingUrl("/auth/login")
                    //실제 post로 값을 받아서 검증을 하는 주소
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/")
                //성공한 뒤 이동하는 페이지
                .and()
                .rememberMe(rm->rm.rememberMeParameter("remember")
                                .alwaysRemember(false)
                                .tokenValiditySeconds(2592000)
                        )
                //로그인 기억하기 위한 메서드
//                .userDetailsService(userDetailsService())
                //-> 안넣어도 아래 Bean으로 등록하면 알아서 적용된다?
                //csrf쪽으로는 builder가 이어지지 않기 때문에 and로 이어준다.
                .csrf(AbstractHttpConfigurer::disable)
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                //로그인 페이지에서 username으로 넘어오는 값 즉, id에 해당하는 값을 받아서
                User user = userRepository.findByEmail(username)
                        //DB에서 해당 값을 찾는다.
                        .orElseThrow(() -> new UsernameNotFoundException(username + "을 찾을 수 없습니다."));
                return new UserPrincipal(user);
            }
        };

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new SCryptPasswordEncoder(
                16,
                8,
                1,
                32,
                64
        );
    }

- 위 코드는 이번 블로그에서 다룰 최종 코드 전체를 작성한 것이고, 아래 자세하게 다룰 것이다.


- Spring Security 클래스 기본 어노테이션

1. @Configuration 어노테이션의 경우 해당 클래스는 설정과 관련된 클래스라는 것을  뜻한다.

2. @EnableWebSecurity 어노테이션의 경우 Spring Security를 활성화하고 설정 클래스를 제공하는 일반적인 방법이다.

 

- WebSecurityCustomizer

1. Spring Security를 사용하여 웹 보안을 구성하는 부분이다 

2. WebSecurityCustomizer 빈을 정의하고, customize 메서드를 구현하여 웹 보안을 커스터마이즈 할 수 있다.

@Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return new WebSecurityCustomizer() {
            @Override
            public void customize(WebSecurity web) {
                web.ignoring()
                        .requestMatchers("/favicon.ico")
                        .requestMatchers("/error")
                        .requestMatchers(toH2Console());
            }
        };
    }

3. WebSecurity 클래스의 .ignoring() 메서드를 통해, Spring Security가 적용되지 않토록 한다.

4. requestMatcher는 Spring Security에서 특정 HTTP 요청을 처리하는 데 사용되는 메서드이다. 즉, requestMatchers를 이용하여 해당 경로로 들어오는 요청에 대해서는 Spring Security를 적용시키지 않겠다는 의미에 해당한다.

 

- SecurityFilterChain

1. SecurityFilterChain은 Spring Security에서 사용되는 보안 필터 체인을 정의하는 인터페이스이다. 이 인터페이스를 통해 보안 필터의 구성을 세부적으로 제어하고, 사용자 정의 보안 로직을 구현할 수 있다.

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        return http
                .authorizeHttpRequests()
//                    .requestMatchers(HttpMethod.POST,"/auth/login").permitAll()
//                    .requestMatchers(HttpMethod.POST,"/auth/signup").permitAll()
                //위의 방법을 사용하면 무한 페이지 리다이렉트 될 수 있다...?
                    .requestMatchers("/auth/login").permitAll()
                    .requestMatchers("/auth/signup").permitAll()
//                .requestMatchers("/user").hasRole("USER")
                .requestMatchers("/user").hasAnyRole("USER", "ADMIN")
                // 관리자는 사용자 페이지도 접근 가능해야 하기 때문에 hasAnyRole을 통해 여러 권한을 줄 수 있다.
//                .requestMatchers("/admin").hasRole("ADMIN")
                //이건 일반적인 경우 사용
                .requestMatchers("/admin").access(new WebExpressionAuthorizationManager("hasRole('ADMIN') AND hasAuthority('WRITE')"))
                //역할과 권한을 둘 다 조건으로 줘야 할 때
                //hasRole과 hasAuthority가 있다.
                //여기서는 ROLE를 붙일 필요는 굳이 없다.
                    //애는 권한 없이도 허용
                    .anyRequest().authenticated()
                    //나머지는 인증해
                .and()
                .formLogin()
                    .loginPage("/auth/login")
                    //로그인 페이지 주소
                    .loginProcessingUrl("/auth/login")
                    //실제 post로 값을 받아서 검증을 하는 주소
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/")
                //성공한 뒤 이동하는 페이지
                .and()
                .rememberMe(rm->rm.rememberMeParameter("remember")
                                .alwaysRemember(false)
                                .tokenValiditySeconds(2592000)
                        )
                //로그인 기억하기 위한 메서드
//                .userDetailsService(userDetailsService())
                //-> 안넣어도 아래 Bean으로 등록하면 알아서 적용된다?
                //csrf쪽으로는 builder가 이어지지 않기 때문에 and로 이어준다.
                .csrf(AbstractHttpConfigurer::disable)
                .build();
    }

2. authorizeRequests는 Spring Security 구성에서 사용되는 메서드 중 하나로, 웹 애플리케이션의 특정 URL 패턴 또는 엔드포인트에 대한 접근 권한을 설정하는 데 사용한다.

.requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/signup").permitAll()

3. .requestMatchers + .permitAll()를 통해 요청 경로에 대해 인증 없이도 허용을 하겠다는 의미를 가진다. 해당 요청 경로는 별도의 인증 과정이 필요없이 접근이 가능하다 -> 로그인이나 회원가입 페이지의 경우 인증, 권한이 필요없기 때문이다.

 

- UserDetailService

- UserDetails는 Spring Security에서 사용자 정보를 표현하기 위한 인터페이스이다. 이 인터페이스는 사용자의 정보와 권한 정보를 제공하는 메서드를 제공하고 있으며, 사용자 인증 및 권한 부여에 사용된다.

.requestMatchers("/user").hasAnyRole("USER", "ADMIN")
//관리자는 사용자 페이지도 접근 가능해야 하기 때문에 hasAnyRole을 통해 여러 권한을 줄 수 있다.
//.requestMatchers("/admin").hasRole("ADMIN")
//이건 일반적인 경우 사용
.requestMatchers("/admin").access(new WebExpressionAuthorizationManager("hasRole('ADMIN') AND hasAuthority('WRITE')"))

1. SecurityFilterChain을 통해 .hasAnyRole / hasRole / access(new WebExpressionAuthorizationManager를 통해 요청에 대한 역할을 지정 할 수 있다.

- 역할과 권한의 구분 (우선 간단히)
* role : 역할 -> 관리자, 사용자, 매니저와 같이 말 그대로 해당 요청에 대해 사용자의 역할을 구분하여 사용할 때 쓰인다.

* authority : 권한 -> 글 쓰기, 글 읽기, 사용자 정지 시키기와 같이 권한은 특정 행동에 대해 부여되는 것을 뜻한다.

2. requestMatchers는 특정 주소의 요청에 대한 설정이기 때문에 해당 요청에 대한 사용자에 대한 정보를 설정하는 부분이 필요하다.

3. SecurityFilterChain의 formLogin을 통해 사용자에 대한 설정을 추가한다. 사용자가 제출한 로그인 정보를 통해 인증을 수행한다.

.formLogin()
.loginPage("/auth/login")
//로그인 페이지 주소
.loginProcessingUrl("/auth/login")
//실제 post로 값을 받아서 검증을 하는 주소
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/")
//성공한 뒤 이동하는 페이지

3. formLogin()을 통해 폼을 통해 전달된 로그인 정보를 통해 인증을 수행한다.

4. loginPage가 /auth/login이기 때문에 폼 데이터를 통해 /auth/login로 POST 요청이 오게되면 Spring Security에서 해당 사용자에 대해 보안 처리를 알아서 해준다.

- AuthService.java

 public void signup(Signup signup) {
        Optional<User> userOptional = userRepository.findByEmail(signup.getEmail());
        if (userOptional.isPresent()) {
            throw new AlreadyExistsEmailException();
        }

        String encryptedPassword = passwordEncoder.encode(signup.getPassword());

        var user = User.builder()
                .email(signup.getEmail())
                .password(encryptedPassword)
                .name(signup.getName())
                .build();
        userRepository.save(user);
    }

5. 회원가입 코드를 보게되면 Spring Security가 제공하는 PasswordEncoder에 의해 넘겨받은 Password가 암호화 된다.

.formLogin()
.loginPage("/auth/login")
//로그인 페이지 주소
.loginProcessingUrl("/auth/login")
//실제 post로 값을 받아서 검증을 하는 주소
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/")
//성공한 뒤 이동하는 페이지

7. SecurityFilterChain에서 확인했던 PasswordParameter로 넘어오는 password의 값은 

@Bean
    public PasswordEncoder passwordEncoder(){
        return new SCryptPasswordEncoder(
                16,
                8,
                1,
                32,
                64
        );
    }

8. 아래 PasswordEncoder가 @Bean으로 등록되어 있기 때문에 DB에 저장된 Password를 비교할 때 Spring Security의 PasswordEncoder에 암호화된 값을 통해 비교된다.

@Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
                //로그인 페이지에서 username으로 넘어오는 값 즉, id에 해당하는 값을 받아서
                User user = userRepository.findByEmail(username)
                        //DB에서 해당 값을 찾는다.
                        .orElseThrow(() -> new UsernameNotFoundException(username + "을 찾을 수 없습니다."));
                return new UserPrincipal(user);
            }
        };

9. @Bean을 통해 UserDetailService가 컨테이너에 등록되는데, UserDetailService는 인터페스는 Spring Security에서 사용자 정보를 로드하고 인증하는데 사용된다.

10. UserDetails를 반환 타입으로 가지고, loadUserByUsername 메서드를 구현해야 한다. 현재 위의 코드는 UserRepository를 통해 사용자 email 정보를 불러온다. (위에서 email을 통해 사용자 정보를 불러온다.)

11. loadUserByUsername의 파라미터는 Spring Security에서 login 경로로 post 요청 받은 usernameParameter로 전달받은 username이 전달된다. (만약 username, password에 대한 사용자 정보가 인증되지 않으면 userDetailsService 메서드가 실행되지 않는다.

12. 로그인이 성공하게 되면 파라미터로 넘어온 username의 값인 email을 통해 DB에서 찾아온 사용자 정보는 UserPrincipal의 생성자의 파라미터로 넘어가고 UserPrincipal 객체를 생성한 값을 return한다. (UserPrincipal은 UserDetails를 상속받는 User 클래스를 상속받는다.)

- UserPrincipal.java

public class UserPrincipal extends User {

    private final Long userId;

    // role: 역할 -> 관리자, 사용자, 매니저
    // authority: 권한 -> 글쓰기, 글 읽기, 사용자정지시키기

    public UserPrincipal(com.hodolog.api.domain.User user) {
        super(user.getEmail(), user.getPassword(),
                List.of(new SimpleGrantedAuthority("ROLE_ADMIN"),
        //이건 사실 역할이 아니라 권한이다. -> 위는 권한이기 때문에 ROLE을 붙이면 알아서 ROLE로 바꿔준다.
        //1. 역할 권한 둘 다 필요할때 권한만 있으면 정삭적인 접근이 안된다.
//                        new SimpleGrantedAuthority("READ")
                        new SimpleGrantedAuthority("WRITE")
                ));
        this.userId = user.getId();
    }

    public Long getUserId() {
        return userId;
    }
}

12. UserPrincipal의 생성자는 super를 통해 부모 클래스인 User 생성자를 호출하는데 생성자를 보게되면

public UserPrincipal(String username, String password, Collection<? extends GrantedAuthority> authorities) {
	super(username, password, authorities);
}

username, password, authorites(GrantedAuthority 사용자 권한을 해당 인터페이스를 통해 구현한 객체로 나타낸다.)를 파라미터로 받는다.

13. DB로부터 전달 받은 인증된 사용자에 대해 GrantedAuthority 인터페이스를 구현하는 클래스를 통해 사용자에 대한 권한을 부여한다.

14. 위에서는 GrantedAuthority 인터페이스의 구현체로 SimpleGrantedAuthority를 사용하였다. (SimpleGrantedAuthority는 Spring Security에서 권한 정보를 표현하는 클래스 중 하나다.)

15. SimpleGrantedAuthority 클래스는 역할을 생성하는 클래스가 아니라 권한을 부여하는 클래스이기 때문에 USER, ADMIN,  WRITE과 같이 원하는 역할 이름을 파라미터로 넘기면 된다.

16. USER, ADMIN 같은 것들은 권한보다는 역할에 가깝기 때문에 SimpleGrantedAuthority를 통해 역할을 부여하고 싶다면 이름앞에 ROLE_을 붙이면 된다. (위에서 한 번 설명했지만 역할은 관리자 사용자와 같이 사용자에 대한 역할을 뜻하고, 권한은 글 쓰기, 읽기와 같이 행동에 대한 것을 권한이라 한다.)

17. 위의 코드에서는 로그인된 사용자에 대해 ADMIN 역할을 부여하고, WRITE 권한을 부여한다.

.requestMatchers("/user").hasAnyRole("USER", "ADMIN")
                // 관리자는 사용자 페이지도 접근 가능해야 하기 때문에 hasAnyRole을 통해 여러 권한을 줄 수 있다.
//                .requestMatchers("/admin").hasRole("ADMIN")
                //이건 일반적인 경우 사용
                .requestMatchers("/admin").access(new WebExpressionAuthorizationManager("hasRole('ADMIN') AND hasAuthority('WRITE')"))

18. SecurityFilterChain으로 다시 돌아가보면, "/user" 경로의 요청으로 USER, ADMIN의 역할을 가진 사용자만 접근이 가능하고, "/admin" 경로의 요청은 역할이 ADMIN이고, 권한은 WRITE인 사용자만 접근이 가능하다.

- MainController.java

@RestController
public class MainController {

    @GetMapping("/")
    public String main(){
        return "메인 페이지 입니다.";
    }

    @GetMapping("/user")
    public String user(){
        return "사용자 페이지입니다.";
    }

    @GetMapping("/admin")
    public String admin(){
        return "관리자 페이지입니다.";
    }
}

19. /user, /admin에 대한 Controller를 생성한다.

- auth.http

### 회원가입

POST http://localhost:8080/auth/signup
Content-Type: application/json

{
  "name": "백인수",
  "email": "saymay10@naver.com",
  "password": "1234"

}

### 로그인

POST http://localhost:8080/auth/login
Content-Type: application/x-www-form-urlencoded

username=saymay10@naver.com&password=1234&remember=1

### 메인 페이지

GET http://localhost:8080

### 사용자 페이지

GET http://localhost:8080/user

### 관리자 페이지

GET http://localhost:8080/admin

20. 회원가입을 하게되면 회원의 정보가 DB에 저장되고, 로그인(x-www-form-urlencoded : 폼 전송)을 하게 되면 해당 사용자에 대한 보안 인증이 이루어진다.

21. 지금까지 작성한 코드에 의하면 위에 로그인된 사용자는 ADMIN 역할의 WRITE 권한이 부여된 상태이기 때문에 /user, /admin 페이 둘 다 접근이 가능할 것이다.


- rememberme(자동 로그인)

- remember-me는 Spring Security에서 사용자가 로그인한 후 웹 애플리케이션을 나가더라도 브라우저가 종료되어 세션이 만료되더라도 사용자를 인증된 상태로 유지할 수 있도록 하는 기능이다.

- remember-me를 사용하게 되면, 사용자의 웹 브라우저에 토큰 또는 쿠키를 저장하여 사용자를 식별하고 인증 정보를 제공해야 한다. 사용자가 로그인하게 되면 remember-me 토큰이 생성되고, 사용자의 브라우저에 저장된다. 후에 사용자가 웹 애플리케이션에 다시 접속할 때 remember-me 토큰을 사용하여 사용자를 자동으로 인증한다.

- SecurityConfig.java

@Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
        return http
.........
                .rememberMe(rm->rm.rememberMeParameter("remember")
                                .alwaysRemember(false)
                                .tokenValiditySeconds(2592000)
                        )
.and()
                .rememberMe(new Customizer<RememberMeConfigurer<HttpSecurity>>() {
                                @Override
                                public void customize(RememberMeConfigurer<HttpSecurity> rm) {
                                    rm.rememberMeParameter("remember")
                                            .alwaysRemember(false)
                                            .tokenValiditySeconds(2592000);
                                }
                            }
                        )

1. rememberMe메서드의 파라미터로 Customizer 객체를 전달하고 Customizer 클래스는 제네릭으로 RememberMeConfigurer클래스를 사용하고 RemeberMeConfigurer 클래스는 제네릭으로 HttpSecurity를 사용한다.

2. rememberMeParameter("remeber")은 form 요청을 통해 remeber의 값이 true 또는 1이 넘어오게 되면 활성화 된다.

- auth.http

POST http://localhost:8080/auth/login
Content-Type: application/x-www-form-urlencoded

username=saymay10@naver.com&password=1234&remember=1

3. 이전에 form 요청을 통해 remeber 값을 전달했던 것을 볼 수 있다.

4. remember-me가 활성화 되면 위에 보는 것과 같이 응답을 통해 사용자에게 remember-me 토큰이 전달되고, 세션, 토큰이 만료되어 사라지더라도 remember-me 토큰을 통해 인증이 되고, 새로운 세션을 발급 받을 수 있다.

 

끝!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

728x90