Spring Security... 그리고 인터페이스

2024. 1. 25. 17:40
728x90

1. Spring Security 설정

- 인증(Authentication) : 사용자가 자신이 누구인지를 증명하는 과정 (로그인)

- 권한/인가(Authorization) : 사용자가 어떤 작업을 수행할 수 있는지를 결정하는 과정.

 

1) 인증 필요 설정.

(인증 필요/ 인증 불필요)

@RestController
public class RootController {
    @GetMapping
    public String root() {
        return "hello";
    }

    @GetMapping("/no-auth")
    public String noAuth() {
        return "no auth success!";
    }

    @GetMapping("require-auth")
    public String reAuth() {
        return "auth success! ";
    }
}

 

2) 인증 관련 설정을 위한 Config 객체 만들기

// WebSecurityConfig class

//@Configuration : @Bean을 비롯해서 여러 설정을 하기 위한 bean 객체
@Configuration 
public class WebSecurityConfig {
    //@Bean : 메서드의 결과를 Bean 객체로 관리해주는 어노테이션
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http) throws Exception {
            
        return http.build();
    }
}

 

3) HttpSecurity 객체로 인증 권한 관련 설정을 적용.

- http.authorizeHttpRequests : HTTP 요청의 인증 여부의 필요를 판단하기 위한 기능을 설정.

- .requestMatchers() : 인자로 전달받은 url 값에 대한 설정을 진행. 

- .permitAll() : requestMatcher()로 넘어온 url들이 인증이 없어도 접근이 가능하도록 설정.

>> /no-auth 요청은 모두 허가해준다. 

@Configuration
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http.authorizeHttpRequests(
                auth -> auth.requestMatchers("/no-auth").permitAll()
                        .requestMatchers("/")
                        .anonymous()
                        .requestMatchers("/require-auth")
                        .authenticated()
        );
        return http.build();
    }
}

** Spring Security 5버전(스프링 부트 2점대 버전)에서는 함수형이 아니라 Builder 패턴으로 썼었다. 6버전(스프링 부트 3점대 버전)에서는 함수형으로 표현하기를 권장. 

            http.authorizeHttpRequests()
                .requestMatchers("")
                .permitAll()

그러면 요렇게 no-auth 경로로 들어가면 인증이 필요 없이 나온다~~~~~

/로 가면 hello, (anonymous)

require-auth로 가면 액세스 거부. 

 

2. Form Login

가장 기본적인 사용자 인증 방식

1) 사용자가 로그인이 필요한 페이지로 이동

2) 서버는 사용자를 로그인 페이지로 이동

3) 사용자는 로그인 페이지를 통해 아이디와 비밀번호 전달

4) 아이디와 비밀번호 확인 후 사용자를 인식.

이후 쿠키를 이용해 방금 로그인한 사용자를 세션을 이용해 기억한다.

 

 

로그인

1) 로그인 페이지와 로그인 후 페이지 매핑

@Slf4j
@Controller
@RequestMapping("users")
public class UserController {

    // 로그인 이전에도 접근 가능
    @GetMapping("/login")
    public String loginForm() {
        return "login-form";
    }

    // 로그인 이후에만 접근 가능.
    @GetMapping("/my-profile")
    public String myProfile() {
        return "my-profile";
    }
}

 

2) 페이지에 따라 config 설정

- .formLogin() 메서드에서 익명 함수를 만든다. 전달받은 formLogin을 통해 설정 진행

- .loginPage() : 로그인 페이지의 URL 설정

- .defaultSuccessUrl() : 로그인 성공 시의 URL  설정

- .failureUrl() : 로그인 실패시의 URL 설정

- .permitAll() : 로그인 관련된 URL의 인증 요구사항 해제.

@Configuration
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http.authorizeHttpRequests(
                        auth -> auth.requestMatchers("/no-auth").permitAll()
                                .requestMatchers("/users/my-profile") //얘는 인증이 필요하다.
                                .authenticated()
                )
                .formLogin(
                        formLogin -> formLogin
                                //어떤 경로로 요청을 보내면 로그인 페이지가 나오는지
                                .loginPage("/users/login")
                                // 아무 설정 없이 로그인에 성공한뒤 이동할 URL
                                .defaultSuccessUrl("/users/my-profile")
                                // 실패시 이동할 URL
                                .failureUrl("/users/login?fail")
                                .permitAll()
                );
        return http.build();
    }
}

>> 이 로그인 설정은 Spring 내부에 정의된 UserDetailsManager / UserDetailsService 인터페이스를 활용해 로그인 과정을 설정하게 된다 !! 아래로 이어짐.

 

3) 비밀번호 암호화를 위한 PasswordEncoder Bean 추가

//WebSecurityConfig
	@Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

 

4) UserDetailsManager (UserDetailsService) 설정

- Spring Security 내부적으로 로그인 과정을 진행하기 위해 사용하는 인터페이스. 

- 개발자가 Bean 객체로 등록할 시 로그인 과정을 커스텀할 수 있다.

- UserDetails라는 인터페이스 + 그 기본 구현체 User 클래스.

- 이렇게 정의해두면 Security 내부에서 UserDetailsService가 필요한 시점에 이 구현체를 대신 사용하게 된다. 

// WebSecurityConfig
	@Bean
    public UserDetailsManager userDetailsManager(
            PasswordEncoder passwordEncoder
    ) {
        UserDetails user1 = User.withUsername("user1")
                .password(passwordEncoder.encode("password1"))
                .build();

        // Spring Security에서 기본으로 제공하는
        // 메모리 기반 사용자 관리 클래스
        return new InMemoryUserDetailsManager(user1);
    }

 

>> 그렇다면 로그인이 실제로 되는지 확인해보자.

- 로그인 성공시에는 users/my-profile로 이동하고, 실패시에는 users/login?fail로 이동한다.

 

 

 

🍪쿠키와 세션

- HTTP 요청에는 상태가 없다. (stateless) = 각 요청은 독립적으로 이루어짐.

즉, 서버는 사용자가 보낸 요청이 몇번째인지 정보를 저장하지 않는다. = 로그인했다는 사실도 서버에 요청할 때마다 알려줘야 한다. 

~> 이 때 사용자의 브라우저에 응답을 보내면서 브라우저에 특정 데이터를 저장하도록 전달해야 한다. 

 

-🍪쿠키 : 서버에서 작성해서 응답을 받은 브라우저가 저장하는 데이터. 브라우저는 동일한 서버에 요청을 보낼 때 🍪 쿠키를 첨부해서 보낸다.

~> 🍪 쿠키에 저장된 ID를 바탕으로 상태를 유지한다.

- 세션 : 상태를 저장하지 않는 HTTP 통신을 사용하면서, 이전에 요청을 보낸 사용자를 기억하는 상태를 유지하는 것.

==> 세션관리도 필요! ! 

접속을 하면 SessionID가 저장된다

로그인 요청 시 이 세션Id를 함께 넘겨준다

🍪 쿠키를 삭제한다면?

새로고침시 로그인 정보가 사라지기 때문에 다시 로그인 창으로 돌아간다.

 

 로그아웃 엔드포인트 설정

- 이미 사용자는 로그인 된 상태 & 세션에 저장 > 로그아웃 : 저장된 세션 정보를 삭제하면 된다. 

- .logout()

- .logoutUrl() : 로그아웃 요청을 받는 로그아웃됨.

- .logoutSuccessUrl() : 로그아웃 성공시 사용자를 이동시킬 URL 

// WebSecurityConfig
	@Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(
                        auth -> auth.requestMatchers("/no-auth").permitAll()
                                .requestMatchers("/users/my-profile")
                                .authenticated()
                )
                .formLogin(
                        formLogin -> formLogin
                                //어떤 경로로 요청을 보내면 로그인 페이지가 나오는지
                                .loginPage("/users/login")
                                // 아무 설정 없이 로그인에 성공한뒤 이동할 URL
                                .defaultSuccessUrl("/users/my-profile")
                                // 실패시 이동할 URL
                                .failureUrl("/users/login?fail")
                                .permitAll()
                )
                .logout(
                        logout -> logout
                                //어떤 경로로 요청을 보내면 로그아웃 되는지 (= 사용자의 세션을 삭제할지)
                                .logoutUrl("/users/logout")
                                // 로그아웃 성공시 이동할 페이지
                                .logoutSuccessUrl("/users/login")
                );
        return http.build();
    }

 

회원가입 구현

1) 회원가입 폼으로 이동하는 엔드포인트 만들기

// UserController.class
@Slf4j
@Controller
@RequestMapping("users")
@RequiredArgsConstructor
public class UserController {
    private final UserDetailsManager manager;
    private final PasswordEncoder encoder;

    // 회원가입으로 이동
    @GetMapping("/register")
    public String registerForm() {
        return "register-form";
    }
}

 

2) 해당 HTML 만들기

더보기
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
</head>
<body>
<main class="flex-shrink-0">
    <section class="py-5">
        <div class="container px-5">
            <!-- login form-->
            <div class="bg-light rounded-3 py-5 px-4 px-md-5 mb-5">
                <div class="row gx-5 justify-content-center">
                    <div class="col-lg-8 col-xl-6">
                        <h1 class="text-center mb-5">회원가입</h1>
                        <form action="/users/register" method="post">
                            <div class="form-floating mb-3">
                                <input class="form-control" id="identifier" name="username" type="text" placeholder="Enter your identifier...">
                                <label for="identifier">ID</label>
                            </div>
                            <div class="form-floating mb-3">
                                <input class="form-control" id="password" name="password" type="password" placeholder="Enter your password...">
                                <label for="password">Password</label>
                            </div>
                            <div class="form-floating mb-3">
                                <input class="form-control" id="password-check" name="password-check" type="password" placeholder="Re-enter your password...">
                                <label for="password-check">Password Check</label>
                            </div>
                            <div class="d-grid"><button class="btn btn-primary btn-lg" id="sign-in-button" type="submit">Submit</button></div>
                            <div style="margin-top: 16px; text-align: right"><a href="/users/login">로그인</a></div>
                        </form>
                    </div>
                </div>

            </div>
        </div>
    </section>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
</body>
</html>

3) 입력된 정보를 바탕으로 가입절차 Controller

- @RequestParam : HTML에서 받은 form 내부 정보를 인자로 받아온다.

- 비밀번호와 비밀번호 체크를 확인하고 같으면 가입절차

- UserDetailsManager를 활용해 가입절차를 완료시킨다. 

@Slf4j
@Controller
@RequestMapping("users")
@RequiredArgsConstructor
public class UserController {
    private final UserDetailsManager manager;
    private final PasswordEncoder encoder;

	@PostMapping("/register")
    public String register(
            @RequestParam("username")
            String username,
            @RequestParam("password")
            String password,
            @RequestParam("password-check")
            String passwordCheck
    ) {
        //todo : password= passwordcheck
        //todo : 주어진 정보를 바탕으로 새로운 사용자 생성
        if (password.equals(passwordCheck)) {
            UserDetails user1 = User.withUsername(username)
                    .password(encoder.encode(password))
                    .build();
            manager.createUser(user1);
        } else {
        //todo : 다를 때 처리
        }
        return "redirect:/users/login";
    }
}

 

4) 가입하기/ 로그인하기 URL로 요청을 받았을 때는 어떤 auth인지를 설정

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(
                        auth -> auth.requestMatchers("/no-auth").permitAll()
                                .requestMatchers("/users/my-profile")
                                .authenticated()
                                .requestMatchers("/users/register","/users/login")
                                .anonymous()
                )
                // 중략
                );
        return http.build();
    }

>> 이렇게 하면 회원가입이 가능하고, 이를 활용해 로그인까지 진행이 가능하다.

 

사용자 정보 확인과 인증 여부에 따른 다른 화면 출력

1) /users/my-profile로 가면 사용자는 로그인한 상태이다. 이때 이 경로의 인자로 Authentication을 추가하여 넘겨주면 이 사용자와 관련된 인증 정보를 확인할 수 있다.

// UserController 
	@GetMapping("/my-profile")
    public String myProfile(Authentication authentication) {
        log.info(authentication.getName());
        log.info(((User) authentication.getPrincipal()).getUsername());
        log.info(SecurityContextHolder.getContext().getAuthentication().getName());
        
        return "my-profile";
    }

- authentication.getName() : 인증 정보의 이름 가져오기

- authentication.getPrincipal() : UserDetails 객체 가져오기 가능. 

매번 Authentication을 가져와서 확인하기 귀찮을 수도 있다. 그럴땐 SecurityContextHolder 활용.

- SecurityContextHolder.getContext().getAuthentication.getName()을 통해서도 가져올 수 있다. (만약 로그인되지 않은 상태라면 결과가 anonymousUser로 나온다.)

 

 

2) 이 정보를 활용해 프론트로 다른 화면도 출력 가능하다.

<!DOCTYPE html>
<html lang="en" xmlns:sec="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>HOME</h1>
<div sec:authorize="isAnonymous()">
    <a href="/users/login">로그인</a>
    <a href="/users/register">회원가입</a>
</div>
<div sec:authorize="isAuthenticated()">
    <h3>반갑습니다, <span sec:authentication="name"></span>님!</h3>
    <form action="/users/logout" method="post">
        <input type="submit" value="로그아웃">
    </form>
</div>
</body>
</html>

 

 

 

 

 

 


겁나 겁나 어렵다. 처음이라 어렵겠지?... 머리 터질거같다....

바다와 같은 백엔드의 세계 ㅎ ㅎ ㅎ ㅎ ...

 

 

** 일단 여기서 

UserDetailsManager라는 interface를 사용할까? 왜 인터페이스를 쓰는지 고민할 필요가 있다.

너무나 추상적인 개념이라서 이해하기가 쉽지가 않았다...  솔직히 맨날 어찌어찌 쓰긴 하는데 명확하게 느낌이 오는 날이 있겠지 하면서.ㅋㅋㅋ

인터페이스를 갖고와서 그 인터페이스의 구현체(implements)를 만들어서 사용을 하는걸까?

 

=> 간단하게 말하면 

구현된 방식에 의존하지 않게 만들기 위해서, 사용하는 개발자가 자유롭게 구현하고, 이 구현체와는 관계없이 인터페이스를 갖고와 사용하면 되니까 ! 인터페이스는 구현한 내부는 몰라도 되게끔.. 그러면서도 메서드는 정해져있기 때문에 일관적이다.

 

UserDetailsManager를 예로 들어서 분석해보았다.

    // 사용자 정보 관리 클래스
    @Bean
    public UserDetailsManager userDetailsManager(
            PasswordEncoder passwordEncoder
    ) {
        UserDetails user1 = User.withUsername("user1")
                .password(passwordEncoder.encode("password1"))
                .build();
                
        return new InMemoryUserDetailsManager(user1);
    }

 

여기서 보면 userDetailsManager의 return값 형태가 UserDetailsManager(interface)이다. 

 

인터페이스 특징 ⤵️

더보기

- 인터페이스는 상수와 추상메서드로 구성되어 있다. 

- 인스턴스를 생성할 수 없다.

- 다른 인터페이스를 extends 키워드로 상속 받을 수 있다.

- 다중 상속이 가능.

- 구현할 때에는 > implements 키워드를 사용하여 구현할 인터페이스를 지정 > 추상메서드를 모두 오버라이드하여 내부 내용 완성

👇UserDetailsManager 인터페이스를 살펴보면 

안에 메서드가 여럿 정의되어있다. create, update, delete, userExists, changePassword 

근데!? return이 InMemoryUserDetailsManager을 생성하여 리턴해주는 것이다. 

그렇담 InMemoryUserDetailsManager는 뭘까하고 탐구하기

얘는 UserDetailsManager 인터페이스를 구현한 구현체 클래스이다! 여기를 보면 여러가지 클래스 자체의 메서드들이 있고, 또 아래로 내려가보면

 

👇 인터페이스를 오버라이딩한 메서드들이 있다. 아까보았던 create, update, delete, userExists, changePassword 모두 있다.

아하~ 이 안에 실제로 어떻게 처리를 할지 구현을 해둔것이다.

InMemoryUserDetailsManager는 Spring Security에서 기본으로 제공하는 메모리 기반 사용자 관리 클래스로서 

UserDetailsManager 인터페이스의 추상메서드들을 모두 구현해둔 클래스인 것이다.

그니까 개발자들이 쓰고 싶으면 써라~라고 만들어준 것.

 

 

여기서 이제 난 내맘대로 하고싶은데!!!!!!?? 라고 한다면..? InMemory는 메모리에만 저장하는 거니까 

나는 DB에다 저장 해주고 싶은데!!????라고 한다면...?

그러면 내 마음대로 커스텀하면 된다. 예를 들어 CustomUserDetails라는 클래스를 만들어, UserDetailsManager를 implements 해준다. 그러면 이렇게 UserDetailsManager에 미리 정의해둔 추상 메서드들을 무조건 오버라이딩해서 내 마음대로 커스텀할 수 있다. 

그리고 얘를 적용하고 싶다? 그러면 아까 반환하는 것을 내가 만든 클래스로 바꿔주면 된다.

    // 사용자 정보 관리 클래스
    @Bean
    public UserDetailsManager userDetailsManager(
            PasswordEncoder passwordEncoder
    ) {
        UserDetails user1 = User.withUsername("user1")
                .password(passwordEncoder.encode("password1"))
                .build();

        return new CustomUserDetailsManager(user1);
    }

짠. 이렇게 아주아주 간단하게 갈아끼우기가 가능하다. 그래서 인터페이스를 굳이굳이 사용하는 것이다.

 

 

🤓정리해보자면🤓

- 인터페이스를 구현한 클래스들을 > 하나의 인터페이스 타입으로 다룰 수 있다. 내가 커스텀 클래스를 아주 여러개 만들어도 같은 인터페이스만 implements 한다면 결국 리턴은 똑같이 된다는 뜻이다. 매우매우 간편하게 갈아끼우기가 가능하다.

- 기능(메서드)의 구현을 강제함으로써 >> 클래스를 일관성 있게 표준화할 수 있다. 인터페이스가 가끔은 제약사항이 될 수도 있다는 말이다. 이 인터페이스를 가져다 쓸거면 이 메서드는 꼭 꼭 꼭 !! 구현해두어야해!!!라는 말. 

 

 

728x90

'Programming > Spring, SpringBoot' 카테고리의 다른 글

Spring의 Dispatcher Servlet  (3) 2024.01.26
CustomUserDetailsManager과 CustomUserDetails 만들기  (1) 2024.01.26
Testing  (1) 2024.01.24
Exception & Validation  (0) 2024.01.23
File Handling (정적파일과 Multipart/form-data)  (0) 2024.01.22

BELATED ARTICLES

more