이전글

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

 

이전글에서 Spring Security 기본개념과 구조이해를 했습니다. 이번 글에서는 Spring Security를 적용해보도록 하겠습니다.

 

 

dependencies

build.gradle에 의존성을 주입해줍니다.

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

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

 

테스트 환경을 위해 타임리프도 추가했습니다.

 

 

 

시작하기

<!-- index.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>
</body>
</html>

 

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }
}

 

이렇게 실행하고 접속해보면

 

 설정하지 않은 로그인 페이지가 뜨고, /login 으로 이동되었습니다. 이는 스프링 시큐리티에서 웹 보안에 가장 기본적인 로그인기능을 지원하기 때문에 나오는건데요.

 

로그에 표시된 password와 username에 'user' 라고 적고 로그인 해봅시다.

 

localhost:8080

성공 적으로 메인페이지로 넘어왔습니다. 우리는 기본적으로 제공하는 로그인 페이지를 사용하지 않고 로그인을 구현할것이기 때문에 일단 모든 요청을 허용해놓겠습니다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.authorizeHttpRequests(request ->
                // 어떤요청이든(anyRequest) 모두허용(permitAll)하겠다.
                request.anyRequest().permitAll()
            );

        return http.build();
    }

}

 

 

 

로그인

 

Security LoginForm 적용 전

 

HTML

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span>메인페이지</span>

    <a href="/join">로그인</a>
    <a href="/signup">회원가입</a>
</body>
</html>
<!-- joinForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/join}" th:method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="submit" value="로그인">
    </form>
</body>
</html>
<!-- signupForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <form th:action="@{/signup}" method="post">
        <input type="text" name="username">
        <input type="password" name="password">
        <input type="text" name="name">
        <input type="submit" value="회원가입">
    </form>
</body>
</html>
<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
</body>
</html>

메인페이지, 로그인, 회원가입, 유저페이지를 구성했습니다.

유저페이지는 로그인 한 유저의 정보를 볼 수 있는 페이지입니다.

 

Domain

@Getter @Setter
@ToString
public class User {

    private String username;
    private String password;
    private String role;
    private String name;
}

User에는 아이디, 비밀번호, 권한, 이름을 저장합니다.

 

 

MainController

@Controller
public class MainController {

    @GetMapping
    public String mainPage() {
        return "index";
    }

    @GetMapping("/user")
    public String userPage(@SessionAttribute("user") String username, Model model) {
        model.addAttribute("user", username);
        return "userForm";
    }

    @GetMapping("/join")
    public String loginPage() {
        return "joinForm";
    }

    @GetMapping("/signup")
    public String signupPage() {
        return "signupForm";
    }
}

HTML과 매핑해줍니다.

 

UserController

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

    @PostMapping("/join")
    public String login(HttpServletRequest request, User user) {
        User findUser = db.find(user.getUsername());
        if (findUser == null || !encoder.matches(user.getPassword(), findUser.getPassword())) {
            return "redirect:/join";
        }
        request.getSession().setAttribute("user", findUser.getUsername());
        return "redirect:/user";
    }
}

로그인과 회원가입 POST 요청을 처리합니다.

 

DB

@Component
@RequiredArgsConstructor
public class MemoryDB {

    private final Map<String, User> db = new HashMap<>();
    private final BCryptPasswordEncoder encoder;

    public User save(User user) {
        user.setPassword(encoder.encode(user.getPassword()));
        db.put(user.getUsername(), user);
        return user;
    }

    public User find(String username) {
        return db.get(username);
    }
}

DB에 유저정보를 저장하고 간단한 예제 구현을 위해 Key값을 아이디로 잡았습니다.

 

 

Config

    @Bean
    BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

비밀번호는 암호화를 위해 BCryptPasswordEncode를 사용했습니다.

 

이렇게 하면 잘 동작할겁니다. 실제로는 예외처리등 수 많은 로직이 존재할겁니다. 이걸 Spring Security에게 위임해보겠습니다.

 

 

 

Security LoginForm 적용 후

먼저 UserController에서 우리가 로그인을 직접 구현했던 @PostMapping("/join") 부분을 제거해줍니다.

@Controller
@RequiredArgsConstructor
public class UserController {

    private final MemoryDB db;
    private final BCryptPasswordEncoder encoder;

    @PostMapping("/signup")
    public String signup(User user) {
        user.setRole("ROLE_USER");
        db.save(user);
        return "redirect:/join";
    }

}

 

 

@RequiredArgsConstructor
public class PrincipalDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(user.getRole()));
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

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

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

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

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

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

 

UserDetails 를 구현해줍니다. 여기에 우리가 구현한 User를 멤버변수로 받고 getUsername과 getPassword를 이어줍니다.

나머지는 일단 true로 둡시다.

 

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemoryDB db;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User findUser = db.find(username);
        if (findUser == null) {
            throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
        }
        return new PrincipalDetails(findUser);
    }
}

 

UserDetailsService를 구현하고 DB와 연동해줍니다. loadUserByUsername의 반환값은 위에서 구현한 PrincipalDetails로 해주고 User 객체를 넣어줍니다.

 

 

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
        );

        http.authorizeHttpRequests(request ->
            request.anyRequest().permitAll()
        );

        return http.build();
    }

SecurityConfig에서 formLogin을 작성해줍니다. formLogin().authorizeHttpRequests() 이렇게 이을 수 있는데 전 따로 분리하는게 더 보기 편하더라구요.

 

메소드 설명
loginPage 로그인 페이지 URL 설정
loginProcessingUrl 로그인 처리 URL 설정 (form action 값)
defaultSuccessUrl 로그인 성공 시 이동할 URL, 두번째 인자인 boolean alwaysUse false가 default
false : 사용자가 보호된 페이지 접근 시도 후 로그인 → 시도했던 페이지로 이동
true : 무조건 defaultSuccessUrl로 설정된 URL로 이동
failureUrl 로그인 실패 시 이동할 URL
usernameParameter 아이디 파라미터명 설정, username이 default
form input name이 username이 아닌경우 (예, email, account 등) 설정
passwordParameter 비밀번호 파라미터명 설정, password가 default
successHandler 로그인 성공 핸들러
failureHandler 로그인 실패 핸들러
disabled 사용안함

 

 

이렇게 설정하고

    @GetMapping("/user")
    public String userPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("user", principalDetails.getUser().getName());
        return "userForm";
    }

SessionAttribute를 제거하고 SecurityContextHolder에 저장된 인증된 사용자의 데이터를 호출하는 @AuthenticationPrincipal을 이용해 데이터를 불러옵니다.

회원가입

 

userForm

로그인 로직을 직접구현하지 않고도 정상적으로 작동되었습니다.

 

 

 

권한설정

여기서 문제가 있습니다.

 

로그인을 하지 않고 /user 로 접속하니 500 에러가 발생했습니다.

java.lang.NullPointerException: Cannot invoke "com.security.demo.config.security.PrincipalDetails.getUser()" because "principalDetails" is null

 

PrincipalDetails에 user를 넣는과정에서 user = null 이 되었기때문에 문제가 발생한건데요. null check를 한다고해도 

로그인하지 않은 사용자가 접근이 가능하다는 것입니다. 이 문제를 해결해보도록 하겠습니다.

User 객체에는 Role 이라는 데이터가 있었습니다. 그리고 회원가입한 User는 "ROLE_USER" 를 부여받습니다. 이 데이터를 이용하겠습니다.

 

몇 가지 가정을 해보겠습니다.

  1. 권한에는 일반 유저("USER")와 관리자("ADMIN") 이 있습니다.
  2. 유저정보를 볼 수 있는 '/user' 는 일반유저와 관리자가 접근할 수 있고,
    관리자정보를 볼 수 있는 '/admin'은 관리자만 접근할 수 있습니다.

 

먼저 관리자 페이지를 간단하게 만들겠습니다.

<!-- adminForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${admin + '관리자님 안녕하세요'}"></span>
</body>
</html>
    @GetMapping("/admin")
    public String adminPage(@AuthenticationPrincipal PrincipalDetails principalDetails, Model model) {
        model.addAttribute("admin", principalDetails.getUser().getName());
        return "adminForm";
    }

 

 

이제 SecurityConfig를 수정해보겠습니다.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.formLogin(login -> login
            .loginPage("/join")
            .loginProcessingUrl("/join")
            .defaultSuccessUrl("/user")
            .failureUrl("/join?error")
        );

        http.authorizeHttpRequests(request -> request
            .requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().permitAll()
        );

        return http.build();
    }

 

/user/** 에 접근하려면 "USER" 또는 "ADMIN" 권한이 존재해야합니다.

/admin/** 에 접근하려면 "ADMIN" 권한이 존재해야합니다.

 

이 권한 PrincipalDetails에 getAuthorities() 에 구현한 데이터를 기반으로 동작합니다. 그런데 우리는 "ROLE_USER" 를 넣었지 "USER"를 넣지는 않았습니다. 그 이유는 GrantedAuthority 기본 규칙은 "ROLE_" 접두사를 사용하는것이 default 이기 때문입니다.

 

조금 더 알고싶거나 "ROLE_" 접두사는 변경하고싶으면

 

 

[Spring Security] 스프링 시큐리티 이해하기 (1)

Spring SecuritySpring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Sprin

tmd8633.tistory.com

이 글 4. Authorization (인가) 부분을 참고하시기 바랍니다.

 

메소드 설명
requestMatchers(String... patterns)
requestMatchers(HttpMethod method, String... patterns)
requestMatchers(HttpMethod method)
특정 URL 패턴에 대한 접근 설정
requestMatchers("/user/**") : /user 하위 URL에 대한 접근 설정
requestMatchers(HttpMethod.GET, "/user"/**") : /user 하위 URL에 GET 접근에 대한 설정
requestMatchers(HttpMethod.GET) : GET 접근에 대한 설정
authenticated() 인증된 사용자만 접근 허용
permitAll() 모든 사용자 접근 허용
denyAll() 모든 접근 거부
hasAuthority(String authority) 특정 권한을 가진 사용자만 접근
hasAuthority("USER") -> "USER" 접근
hasAnyAuthority(String... authorities) 여러 권한 중 하나라도 가진 사용자만 접근
hasRole(String role) 특정 권한을 가진 사용자만 접근 (ROLE_ 접두사 자동 추가)
hasRole("USER") -> "ROLE_USER" 에 접근
hasAnyRole(String... roles) 여러 역할 중 하나라도 가진 사용자 접근 (ROLE_ 접두사 자동 추가)

 

 

결과

로그인이 되어있지 않은 유저가 /user 로 접근하는 경우 /join 으로 redirect되어 로그인이 진행된 후에 /user 접근이 됩니다.

만약 USER가 ADMIN에 접근하려는 경우 403 Forbidden 이 발생합니다.

 

 

로그아웃

<!-- userForm.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <span th:text="${user + '님 안녕하세요'}"></span>
    <form th:action="@{/logout}" th:method="post">
        <input type="submit" th:value="로그아웃">
    </form>
</body>
</html>

userForm.html에서 로그아웃 form을 만들어줍니다.

 

        http.logout(logout -> logout
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
        );

http.logout을 설정해주면 로그아웃도 완성입니다.

 

로그아웃 설정

메소드 설명
logoutURL 로그아웃 처리 URL default : "/logout"
logoutSuccessUrl 로그아웃 성공 시 이동할 URL
logoutSuccessHandler 로그아웃 성공 시 핸들러
addLogoutHandler 로그아웃 핸들러 추가
clearAuthentication SecurityContext에서 인증정보 제거 default : true
invalidateHttpSession 세션 무효화 default true
deleteCookies(String... cookieNamesToClear) 삭제할 쿠키 지정
logoutRequestMatcher 로그아웃 요청 매처 설정
예) GET으로 로그아웃 요청 시 .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))

 

 

결론

Spring Security에서 가장 기본적으로 다루는 부분을 구현해보았습니다.  아직 알아야할거도 많고 추가된것들도 많아서 공부를 계속해 나가면서 시리즈 더 추가해보겠습니다.

 

CSRF란?

CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를 악용하여 사용자의 의도와는 무관한 작업을 수행하게 만듭니다. 다시 말해 인증된 요청과 위조된 요청을 구분하지 못하고 서버에서 요청을 처리하여 문제가 생기는 것을 말하는데요. 웹 개발자라면 반드시 알아야하는 부분입니다. 오늘은 이것에 대해서 알아보도록 하겠습니다.

 

 

 

CSRF 공격

CSRF 공격에 대해서 예시와 함께 알아보도록하겠습니다.

 

예시로 은행 웹사이트에서 로그인한 사용자로부터 돈을 이체할 수 있는 Form이 있다고 가정하겠습니다.

<form method="post" action="/transfer">
    <input type="text" name="amount"/>
    <input type="text" name="account"/>
    <input type="submit" value="Transfer"/>
</form>

HTTP 요청은 다음과 같습니다.

POST /transfer HTTP/1.1
Host: bank.jours.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=10000&account=7654

 

 

다음은 은행 웹사이트에서 로그아웃하지 않고 위조된 웹사이트를 방문한다고 가정하겠습니다. 그 웹사이트에는 다음과 같은 HTML이 있습니다.

<form method="post" action="https://bank.jours.com/transfer">
    <input type="hidden" name="amount" value="100000"/>
    <input type="hidden" name="account" value="1234"/> <!-- 공격자 계좌번호 -->
    <input type="submit" value="Win Money!"/>
</form>

 

위조된 웹사이트에서 submit을 하면 공격자 계좌로 송금이 될겁니다. 이는 위조된 웹사이트가 사용자의 쿠키를 볼 수 없지만 은행과 관련된 쿠키는 여전히 남아 요청과 함께 전송되기 때문에 발생합니다. 더욱 큰 문제는 버튼을 클릭해 submit 하지 않아도 JavaScript를 사용하여 자동화하여 제출할 수 있다는 것입니다. 그렇다면 어떻게 이 문제를 해결할 수 있을까요?

 

 

CSRF 방어

 

읽기전용 메소드

CSRF를 방어하기위해서는 읽기전용 메소드가 선행되어야 합니다.

HTTP Method중 GET, HEAD, OPTIONS, TRACE 메소드는 반드시 읽기전용 메소드가 되어야합니다.

// 잘못된 예시 - GET으로 데이터 변경
@GetMapping("/user/delete/{id}")  // ❌ 절대 하면 안 됨
public void deleteUser(@PathVariable Long id) {
    userService.deleteUser(id);
}

// 올바른 예시 - POST로 데이터 변경
@PostMapping("/user/delete/{id}")  // ✅ 올바른 방법
public void deleteUser(@PathVariable Long id) {
    userService.deleteUser(id);
}

 

 

1. Synchrozier Token Pattern

form 안에 CSRF 토큰을 넣어주는겁니다. 그러면 서버는 토큰을 조회하여 값이 일치하지 않으면 요청을 거부할 수 있게됩니다. 핵심은 쿠키는 브라우저에서 자동으로 HTTP 요청에 포함되지만 CSRF 토큰이 브라우저에서 자동으로 포함되지 않는다는 것입니다.

 

<form method="post" action="/transfer">
    <input type="text" name="amount"/>
    <input type="text" name="account"/>
    <input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
    <input type="submit" value="Transfer"/>
</form>

 

HTTP 요청은 다음과 같습니다.

POST /transfer HTTP/1.1
Host: bank.jours.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100000&account=7654&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721

 

<!-- Thymeleaf 에서 @{} 를 사용하면 자동으로 CSRF 토큰이 포함됨 -->
<form th:action="@{/login}" method="post">

 

 

SameSite

쿠키에 SameSite 속성을 지정하는 것입니다. 서버는 SameSite 속성을 지정하여 외부 사이트에서 오는 쿠키를 보낼지 여부를 정할 수 있습니다.

 

 

설정값 설명
Strict
  • 가장 엄격한 설정
  • 같은 도메인의 요청에서만 쿠키 전송
  • 외부 사이트에서의 모든 요청에 쿠키를 보내지 않음
  • 보안성은 가장 높지만, 사용자 경험을 해칠 수 있음
Lax
  • TOP-level 네비게이션(주소창에 직접 입력, <a> 태그 클릭)에서는 쿠키 전송 허용
  • POST, PUT, DELETE 등의 요청에서는 쿠키 전송 제한
  • Strict보다 유연하면서도 기본적인 보안 제공
None
  • 모든 크로스 사이트 요청에 쿠키 전송 허용
  • 반드시 Secure 플래그와 함께 사용해야 함
  • HTTPS가 필수

 

 

# application.properties
# strict, lax, none 중 설정
server.servlet.session.cookie.same-site=strict

 

Spring Boot 2.6.0 이상에서는 SameSite=Lax 가 Default입니다.

 

SameSite 주의점

  • 브라우저가 SameSite를 지원하지 않을 수 있습니다. 예전 브라우저를 사용한다면 SameSite가 지원되지 않을 수 있습니다.
  • strict 설정 시  social.jours.com - email.jours.com 과의 쿠키는 전송되지않습니다.

 

 

 

REST API의 CSRF 설정

출처 : https://docs.spring.io/spring-security/reference/features/exploits/csrf.html#csrf-protection-read-only

 

REST API에서는 CSRF를 disabled 해도 괜찮다고합니다. 왜냐하면 API 요청시 인증정보(Jwt, OAuth2, Client Key 등)를 포함하여 전송하기 때문에 불필요하게 CSRF 인증정보를 저장하지 않아도 되는 것입니다.

 

 

 

Spring Security 에서의 CSRF 설정

Spring Security 에서 CSRF를 설정할 수 있습니다. 기본적으로 CSRF 토큰을 지원합니다.

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.csrf(Customizer.withDefaults());

        return http.build();
    }

 

csrf 설정 메소드

http.csrf(csrf -> {
    csrf
        // CSRF 완전 비활성화
        .disable()                     
        
        // 특정 경로 CSRF 검증 제외
        .ignoringAntMatchers("/api/**")  
        
        // RequestMatcher로 더 복잡한 조건으로 제외할 경로 설정
        .ignoringRequestMatchers(requestMatcher)  
        
        // CSRF 토큰 저장소 설정
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        
        // 커스텀 CSRF 토큰 저장소 설정
        .csrfTokenRepository(new CustomCsrfTokenRepository())
        
        // CSRF 토큰 생성 요청 처리 경로 설정 (기본값: "_csrf")
        .csrfTokenRequestHandler(requestHandler)
        
        // 세션 속성 이름 설정 (기본값: "CSRF_TOKEN")
        .sessionAuthenticationStrategy(sessionAuthenticationStrategy)
        
        // CSRF 토큰 필터 이전에 실행될 필터 추가
        .requireCsrfProtectionMatcher(requestMatcher)
});
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 는 JavaScript에서 CSRF 토큰을 사용 할 수 있도록 쿠키에 저장하는 것인데, 위에서 설명했듯이 API는 csrf.disabled() 해서 사용하는 것이 더 유용할 수 있습니다.

 

 

결론

CSRF 공격은 웹 애플리케이션의 중요한 보안 위협이지만, 적절한 방어 메커니즘을 구현함으로써 효과적으로 방어할 수 있습니다. 특히 CSRF 토큰, SameSite 쿠키 설정, 그리고 적절한 헤더 검증을 조합하여 사용하는 것이 권장됩니다.

'일반 > CS' 카테고리의 다른 글

[CS][Spring Security] CORS에 대해서 알아보자  (0) 2024.12.16
Maven Central Repository에 라이브러리 등록하기  (0) 2024.09.19
[CS] MVC 패턴  (0) 2024.04.17
HTTP GET과 POST 차이  (0) 2024.01.25
URI와 URL의 차이점 (Feat : URN)  (0) 2024.01.21

Spring Security

Spring Security는 인증, 인가 및 일반적인 공격에 대한 보호를 제공하는 프레임워크 입니다. 서비스를 개발할때 필수적으로 구현해야하는것이 로그인, 회원가입과 권한인데요. 이를 Spring Security를 통해 쉽게 해결할 수 있습니다. 다만 이 프래임워크가 워낙 방대하고 구조를 이해하고 있지않으면 활용하기 쉽지 않습니다. 하나의 게시물로 모든 Security 기능에 대해서 소개하지 못하므로, 이 글에서는 프레임워크에 대해 기본개념과 주요기능에 대해서 소개하고 각 기능에 대한 상세한 설명은 다음 글에서 이어가도록 하겠습니다.

 

이 글은 Spring Boot 3.4.0, Spring Security 6.4.1 환경에서 제작되었습니다.

 

 

 

들어가기전에

Security를 공부하기전에 짧게 기본개념을 잡고 가겠습니다. 이해하는데 도움이 될겁니다.

 

 

1. Filter

Spring Security는 Servlet Filter를 기반으로 동작합니다.

Client - Filter - DispatcherServlet - Interceptor - Controller

 

 

2. 용어

용어 설명
Authentication 인증
  • 사용자가 자신이 주장하는 사람이 맞는지 확인하는 과정
  • '신원 확인'의 과정
    예) 공항에서 여권으로 신원을 확인하는 것
Authorization 인가
  • 인증된 사용자가 특정 리소스에 접근할 수 있는 권한이 있는지 확인하는 과정
  • '권한 확인'의 과정
    예) 회사에서 직급에 따라 접근할 수 있는 시스템이 다른 것
Principal 접근주체
  • 시스템에 접근하는 사용자, 디바이스 또는 다른 시스템 등의 주체
  • 인증 이후 SecurityContext에 저장되는 인증된 사용자의 정보
    예) 사원증을 소지한 직원
Role 권한
  • 특정 리소스에 대한 접근 권한을 그룹화한 것
  • 사용자에게 부여될 수 있는 권한의 집합
    예) 회사에서의 직급 (사원, 대리, 과장 등)

 

용어 간의 관계

1. Authentication -> Principal 생성
   (인증 성공 시 Principal 정보가 생성됨)

2. Principal -> Role 부여
   (인증된 사용자에게 역할이 부여됨)

3. Role -> Authorization 결정
   (부여된 역할에 따라 권한이 결정됨)

 

 

3. SecurityContextHolder

SecurityContextHolder는 인증된 사용자의 정보를 저장하는 곳입니다.

 

 

4. Filter, Authentication, Authorization

Security에서 크게 Filter, Authentication, Authorization 으로 나눌 수 있습니다. 아키텍처를 볼 때, 이렇게 3개로 구분해서 보면 이해가 조금 더 쉬울 수 있습니다.

 

 

 

 

 

 

1. 아키텍처

 

출처 : https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

 

시큐리티 공식문서에서 가져온 아키텍처입니다. 이 사진을 보고 이해해야할것은 아래와 같습니다.

 

  1. SecurityFilterChain 은 Filter에서 동작한다.
  2. Authentication을 통해 인증과정을 수행한다.
    인증에 성공하면 SecurityContextHolder에 인증된 사용자의 정보를 담는다.
    인증에 실패하면 SecurityContextHolder가 비워집니다.(cleared out)

 

 

2. Filter

 

2-1.  DelegatingFilterProxy

 

서블릿 컨테이너와 Spring의 ApplicationContext 사이의 연결점입니다. 서블릿 컨테이너는 자체 표준을 사용하여  필터 인스턴스를 등록할 수 있지만 Bean에 대해서는 알지 못합니다. 그래서 DelegatingFilterProxy 에 Bean 등록을 위임하도록 합니다.

 

한줄정리 : DelegatingFilterProxy는 Servlet Container - Spring ApplicationContext를 연결하는 역할을 한다!

 

 

2-2. FilterChainProxy과  SecurityFilterChain

 

  1. FilterChainProxy는 보안필터의 시작점입니다.
  2. 요청 URL에 따라(RequestMatcher) 적절한 SecurityFilterChain을 선택할 수 있도록 합니다.
  3. Spring Security의 HttpFirewall을 적용하여 특정 유형의 공격으로부터 애플리케이션을 보호합니다.
  4. FilterChianProxy는 bean 이기때문에 DelegatingFilterProxy로 감싸져있습니다.

 

 

요청 URL에 따라 SecurityFilterChain에서 Filter를 선택

 

 

 

2-3. SecurityFilterChain 내부 Filter

 

spring-security/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java at

Spring Security. Contribute to spring-projects/spring-security development by creating an account on GitHub.

github.com

SecurityFilterChain에는 여러 Filter가 존재하는데 그 Filter의 순서는  FilterOrderRegistration.class 를 통해 확인할 수 있습니다. Filter의 순서를 외울필요는 없지만 어느 정도 흐름은 알고있는게 좋습니다. 

각 Filter의 용도와 사용법은 다음 글에서 알아보도록하겠습니다.

 

 

 

 

3.  Authentication (인증)

사용자 정보를 가지고 인증을 하는 과정입니다.

 

3-1. SecurityContextHolder

 

 

  • SecurityContextHolder는 인증된 사용자의 정보를 담고있는 Container입니다. Spring Security는 SecurityContextHolder가 어떻게 채워지는지 묻지않습니다. 그저 값이 포함되어있으면 인증된 사용자로 인식합니다. 즉, SecurityContextHolder가 비어있으면 인증되지않은 사용자, 비어있지않으면 인증된 사용자로 인식됩니다.
  • ThreadLocal을 사용하여 보안정보를 저장합니다. 즉, SecurityContext는 동일한 스레드내에서는 어디서든 보안 정보를 쉽게 접근 가능합니다. 요청 처리가 끝나면 반드시 쓰레드의 정보를 지워야 하지만 FilterChainProxy가 이 청소를 자동으로 해주기때문에 매우 안전하게 사용할 수 있습니다.
  • ThreadLocal에 대해 안전하지않은 어플리케이션에 대해서는 ThreadLocal 설정을 변경할 수 있습니다.
 

[JAVA] ThreadLocal에 대해서 알아보자

개요Spring Security 에서 ThreadLocal에대해서 언급한적이 있어 글로 남겨봅니다. ThreadLocal이란?ThreadLocal은 Java에서 제공하는 클래스로, java.lang 패키지에 존재합니다.각 스레드마다 독립적인 변수를

tmd8633.tistory.com

 

3-2. SecurityContext

인증된 객체(Authentication)를 보관하는 역할을 합니다. SecurityContextHolder로 부터 가져올 수 있습니다.

 

 

3-3. Authentication

현재 접근하는 사용자의 정보와 권한을 담은 인터페이스 입니다. isAuthenticated() 메소드로부터 인증되었는지 확인할 수 있습니다.

 

용어 설명
principal 아이디/비밀번호로 인증할때 사용자를 식별하는 역할을 합니다. UserDetails의 인스턴스로 사용됩니다.
credentials 비밀번호로 사요됩니다. 사용자가 인증된 후에는 비밀번호가 지워집니다.
authorities 사용자에게 부여된 권한들입니다. GrantedAuthority의 List형식으로 되어있습니다.

 

GrantedAuthority

주체에게 부여된 권한입니다. "ROLE_ADNATIOR", "ROLE_HR_SUPERVISOR" 과 같은 '역할'로 사용됩니다.

사용자 아이디/비밀번호 기반 인증을 사용할 때, UserDetailsService에 의해 로드됩니다.

 

 

3-4. AuthenticationManager

AuthenticationManager는 Spring Security의 필터가 인증을 수행하는 방식을 정의하는 인터페이스입니다.

AuthenticationManager는 인터페이스이므로 따로 구현할 수 있지만 기본적으로 ProviderManager가 구현되어있습니다.

 

 

3-5. AuthenticationProvider

실제 인증에 대한 부분을 담당합니다. 인증전의 Authentication 객체를 받아서 인증이 완료된 Authentication 객체를 반환합니다.

 

 

3-6. UserDetailService

UserDetailService를 implements해서 DB 데이터를 주입하여 UserDetails 를 반환하게 합니다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

 

 

3-7. UserDetails

인증된 사용자로써 사용될 인터페이스 입니다. implements해서 Custom해야합니다.

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    default boolean isAccountNonExpired() {
        return true;
    }

    default boolean isAccountNonLocked() {
        return true;
    }

    default boolean isCredentialsNonExpired() {
        return true;
    }

    default boolean isEnabled() {
        return true;
    }
}

 

 

4. Authorization (인가)

인증된 정보를 가지고 접근에 대한 허용여부를 결정하는 과정입니다. GrantedAuthority에 String getAuthority() 메소드가 사용됩니다.

기본적인 규칙으로 'ROLE_' 접두사가 포함됩니다. 즉, "USER"라는 권한이 있는 경우 GrantedAuthority#getAuthority 를 사용하면 "ROLE_USER" 권한을 찾습니다.

 

'ROLE_' 접두사를 변경할 수 있습니다.

@Bean
static GrantedAuthorityDefaults grantedAuthorityDefaults() {
    return new GrantedAuthorityDefaults("MYPREFIX_");
}
// static 필수!!

 

 

 

 

5. 인증처리 과정

 

 

5-1. HTTP 요청

사용자가 로그인 요청을 합니다.

 

5-2 AuthenticationFilter 인증

SecurityFilterChain에서 로그인을 담당하는 UsernamePasswordAuthenticationFilter 에서 인증을 처리합니다.

 

5-3. UsernameAuthenticationToken 발급

아이디와 비밀번호를 통해 토큰을 발급하고 AuthenticationManager로 토큰을 보냅니다.

 

5-4. AuthenticationProvider 인증

인증된 객체를 UserDetailsService에게 넘겨줍니다.

 

5-5. UserDetails 생성

전달된 인증된 객체를 DB에서 조회하여 데이터를 UserDetails 반환

 

5-6. AuthenticationProvider

인증에 성공하면 AuthenticationProvider에서 AuthenticationManager에게 인증에 성공한 객체를 반환

 

5-7. AuthenticationManager

인증에 성공한 객체를 AuthentifacionFIlter에 전달

 

5-8. SecurityContextHolder

인증에 성공한 객체는 SecurityContextHolder에 저장

 

 

 

6. 설정

SecurityFilterChain 에 어떤 기능을 사용할지 설정하는 부분입니다. 이 부분에서는 Filter의 기능을 사용자의 API에 맞게 수정할 수 있고, 해당 Filter의 전과 후에 Custom Filter를 넣는 등 여러 기능을 설정할 수 있습니다.

 

6-1. 기본설정

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        http.csrf(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults())
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            );

        return http.build();
    }

}

 

메소드 필터 설명
csrf CsrfFilter csrf에 대한 설정
httpBasic BasicAuthenticationFilter HTTP Basic Authentication에 대한 설정
formLogin UsernamePasswordAuthenticationFilter form login에 대한 설정
authorizeHttpRequests AuthorizationFilter 권한에 대한 설정

 

위의 4개의 메소드뿐만아니라 수많은 filter에 대한 설정을 할 수 있습니다.

 

Customizer.withDefaults() 는 Security 기본설정에 따른다는 메소드입니다.

 

CSRF에 대해서 이해하기

 

[CS][Spring Security] CSRF란?

CSRF란?CSRF Cross-Site Request Forgery의 약자로 인증된 사용자의 권한을 악용하여 해당 사용자가 의도하지 않은 요청을 웹사이트에 전송하는 공격 기법입니다. 공격자는 사용자가 이미 인증된 상태를

tmd8633.tistory.com

 

 

6-2. Filter 추가

http.

// atFilter를 filter로 변경
addFilterAt(Filter filter, Class<? extends Filter> atFilter)

// beforeFilter 전에 filter를 추가
addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter)

// afterFilter 후에 filter를 추가
addFilterAfter(Filter filter, Class<? extends Filter> afterFilter)
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class); 
    return http.build();
}

 

 

Filter를 Bean으로 등록

Filter를 Bean으로 선언하면 Spring Container에 등록되면서 Filter가 2번 호출 될 수 있습니다. 이를 방지하기 위해 setEnabled(false) 로 설정해주어야 합니다.

 

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

이렇게 하면 HttpSecurity 에 추가한 것만 유효하게 됩니다.

 

 

 

참고자료

공식문서 : https://docs.spring.io/spring-security/reference/servlet/architecture.html

 

+ Recent posts