웹 애플리케이션을 보호하는 방법을 살펴보기 위해 Spring Boot 프로젝트를 생성해보고 Spring Seuciry를 추가하여 보안 설정을 하는 방법에 대해 배워보자
프로젝트 유형: Maven
언어: Java
Spring Boot 버전: 3.2.2
Group: com.eazybytes
Artifact: springsecuritybasic
Package name: com.eazybytes.springsecuritybasic
Java 버전: 17
Dependencies: Spring Web, DevTools
Spring Boot DevTools는 생산성을 증진시킴으로써 로컬 환경의 개발을 돕는 dependency이다.
더빠른 애플리케이션 재시작과 Live Coding을 제공하기 때문에 개발자들의 생산성을 향상시킨다.
Spring Boot 웹 애플리케이션을 수동으로 재시작할 필요가 없다. 그리고 이러한 안 보이는 곳에서의 DevTools 도움으로 자동 재시작이 일어날 것이다.
이러한 재시작이 일어날 때 Spring Boot는 변화가 있는 클래스에만 로딩하기 때문에 재시작이 빠르게 일어난다.
SpringSecurityBasicApplication.java: Spring Boot 애플리케이션의 메인 클래스
src/main/java/com/eazybytes/springsecuritybasic/controller: 컨트롤러 패키지
WelcomeController.java: REST 서비스를 제공하는 컨트롤러 클래스
package com.eazybytes.springsecuritybasic.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WelcomeController {
@GetMapping("/welcome")
public String sayWelcome() {
return "Welcome to Spring Application with out Security";
}
}
< 실행 결과 >
현재 애플리케이션은 보안이 적용되지 않아, 아무나 REST 서비스를 호출하고 응답을 받을 수 있다. 프로덕션 환경에서는 이러한 상태가 바람직하지 않다.
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
웹 애플리케이션을 재시작한다.
브라우저에서 localhost:8080/welcome에 접근하면, 현재는 아무런 반응이 없을 것이다. Spring Security는 자격 증명을 요구한다.
Spring Security에서 기본으로 제공하는 유저네임은 'user'이다.
브라우저에서 입력된 유저네임과 함께 비밀번호를 입력해야 요청한 페이지로 이동 가능하다.
콘솔에서 초기 비밀번호를 확인하고, 해당 비밀번호로 로그인한다.
Spring Security를 통해 자동 로그인이 구현되어, 첫 번째 요청 이후에는 자격 증명을 다시 묻지 않는다.
Spring Security는 세션 ID나 토큰 세부 정보를 저장하여 동일한 브라우저에서는 계속해서 자동 로그인을 유지한다.
현재의 설정은 서버를 재시작할 때마다 랜덤으로 비밀번호가 생성되는 불편한 상황이 있으므로, 고유한 사용자 정보를 설정하여 이를 해결해보자.
application.properties 파일을 열어서 유저 이름과 비밀번호를 설정한다.
Spring Security에서 지원하는 속성들을 참고하여 유저 이름, 비밀번호, 역할 등을 변경할 수 있다.
# application.properties
# 사용자 이름 설정
spring.security.user.name=eazybytes
# 비밀번호 설정
spring.security.user.password=12345
# 역할 설정 (옵션)
# spring.security.user.roles=ROLE_USER
+) 참고로 spring.io의 공식문서에 보면 applicaion.properties에서 사용할 수 있는 속성들이 아주 잘 정리되어 있다.
구글링을 하고 강의를 찾아보는 것도 좋지만 공식문서를 찾아보고 적용하는 습관을 들이자
spring.security.user.name: 원하는 사용자 이름을 설정한다. 여기서는 'eazybytes'라는 이름으로 설정
spring.security.user.password: 사용자의 비밀번호를 설정한다. 여기서는 '12345'라는 비밀번호로 설정
spring.security.user.roles (옵션): 사용자의 역할을 설정할 수 있다. 여기서는 다루지 않았지만, 역할 기반의 접근 제어에 활용된다.
위의 설정은 개발 환경이나 테스트를 위한 용도로 사용하며, 실제 운영 환경에서는 더 안전하고 유연한 방법을 사용해야 한다.
실제로는 데이터베이스 등을 활용하여 사용자 정보를 관리하고 Spring Security의 고급 기능을 활용한다.
변경된 내용을 저장하고 Spring Boot 애플리케이션을 재시작한다.
브라우저에서 localhost:8080/welcome에 접근한다.
설정한 사용자 이름과 비밀번호로 로그인하여 REST 서비스에 접근한다.
이제 서버를 재시작할 때마다 랜덤 비밀번호를 찾을 필요가 없습니다.
설정한 사용자 정보로 웹 애플리케이션에 접근할 수 있습니다.
Spring Security 사용해야 하는 이유와 어떻게 웹 애플리케이션을 효과적으로 보호할 수 있는지에 대해 알아보자.
개발자가 고유의 코드나 프레임워크를 사용하여 웹 애플리케이션을 보호하려면 매우 어려운 일이다.
매일 수백 개의 보안 취약점이 발견되며, 보안 위반 사례가 늘어나는 상황에서 항상 주의가 필요하다.
해커들은 새로운 취약점을 찾아내고 있으며, 매일매일 코드를 업데이트하는 것은 현실적으로 어렵다.
Spring Security는 CSRF(Cross-Site Request Forgery), CORS(Cross-Origin Resource Sharing)와 같은 일반적인 보안 취약점 뿐만 아니라 다양한 기능들을 제공한다.
또한 역할 기반의 권한 제어, 메소드 레벨의 보안 설정 등 다양한 보안 수준을 설정할 수 있다.
Spring Security는 다양한 인증 및 권한 부여 기능을 제공한다.
사용자 이름과 비밀번호를 이용한 기본적인 HTTP basic을 사용하거나, JWT 토큰이나 Open ID와 같은 고급 기능을 활용하여 웹 애플리케이션을 안전하게 보호할 수 있다.
Spring Security는 기본 설정에서 모든 API를 보호하며, 사용자가 자격증명 정보를 입력할 수 있는 로그인 페이지를 제공한다.
또한 사용자가 로그인한 후에는 추가적인 자격증명을 요구하지 않고 계속해서 웹 애플리케이션에 접근할 수 있다.
Spring Security 내부 흐름을 이해하기 전에, Java 웹 애플리케이션에서의 서블릿(Servlets)과 필터(Filters)에 대한 개념을 정리해보자.
서블릿은 요청을 받아 HTTP 프로토콜로 변환하고, 필터는 웹 애플리케이션으로 들어오는 모든 요청을 가로채기 위해 사용된다.
자바 웹에서 서블릿 컨테이너 또는 웹 서버는 자바 코드가 이해할 수 있는 형식으로 사용자의 웹 요청을 처리한다.서블릿 컨테이너는 이를 가능하게 하는 중요한 역할을 한다. 가장 대표적으로 사용되는 서블릿 컨테이너는 아파치 톰캣이다.
우리의 브라우저가 웹 서버로 HTTP 메시지를 전송하면, 이 메시지는 자바 코드에서 이해할 수 있는 형태로 변환되어야 한다.
이 변환 역할을 서블릿 컨테이너가 담당한다. 예를 들어, 사용자의 요청이 있으면 서블릿 컨테이너는 해당 요청을 Servlet Request로 변환하고, 이를 서블릿 메서드의 파라미터로 전달한다.
그 후, 서블릿은 해당 요청에 대한 작업을 수행하고, 처리 결과를 Servlet Response로 반환한다. 이는 다시 서블릿 컨테이너를 통해 사용자의 브라우저에 전송된다.
이렇게 함으로써, 우리가 자바 웹에서 작성하는 모든 코드는 사실상 서블릿에 의해 구동되는 구조를 가지게 된다.
이를 간단히 정리하면 아래와 같다.
클라이언트가 백엔드 웹 애플리케이션에 요청을 보내면, 서블릿 컨테이너(=웹 서버)가 이를 처리하여 ServletRequest object로 변환한다.
서블릿은 이 요청을 웹 애플리케이션에 전달하고, 응답을 받아 HTTP ServletResponse object로 변환하여 클라이언트에게 반환한다.
필터는 클라이언트와 실제 서블릿 사이에서 모든 요청을 가로채고, 로직을 실행하기 전에 사전 로직이나 프리-워크를 정의할 수 있다.
Spring Boot와 Spring 프레임워크는 서블릿과 필터를 사용하여 웹 애플리케이션의 복잡한 로직을 처리한다.
개발자는 REST 서비스, MVC paths 등을 정의하면, Spring이 서블릿과 필터를 생성하고 관련된 로직을 처리한다.
Spring Security는 웹 애플리케이션에서 보안을 시행하기 위해 서블릿과 필터를 활용한다.
필터를 사용하여 설정에 따라 보안을 적용하며, 이를 통해 Spring Security가 자체적으로 동작하게 된다.
Spring Security의 내부 흐름에 대해 알아보자. 사용자가 로그인 페이지에 자격 증명을 입력하고, 백엔드 서버로 요청을 보내는 첫 번째 단계에서 시작된다.
사용자는 브라우저, 모바일 애플리케이션, 또는 Postman과 같은 도구를 사용하여 자격 증명과 함께 백엔드 웹 애플리케이션에 요청을 전송한다.
서블릿 컨테이너에서 이 요청을 수신하면, Spring Security 프레임워크에 의해 등록된 여러 필터들이 요청을 감시한다.
이들 필터는 요청이 보호된 자원인지를 확인하고, 보호가 필요한 경우 인증을 강제한다.
Spring Security 필터들은 엔드 유저의 요청이 보호된 자원에 접근하는 것인지 판별합니다.
첫 번째 요청인 경우, 로그인 페이지를 표시하고 엔드 유저에게 자격 증명을 요청합니다.
유저가 제공한 자격 증명은 Spring Security 필터에 의해 추출되고, 2단계에서는 이를 인증 객체로 변환한다.
이 객체는 유저의 정보(ex> userName, password 등)를 저장하는 중요한 역할을 한다.
이후, 필터는 이 정보를 AuthenticationManager에게 전달한다.
AuthenticationManager는 실질적인 인증 로직을 관리하는 관리 인터페이스 또는 클래스이다.
AuthenticationManager는 현재 웹 애플리케이션에 등록된 AuthenticationProvider들을 확인하고, 각각의 AuthenticationProvider에게 인증을 시도하도록 요청한다.
AuthenticationProvider는 인증을 위해 사용자 세부 정보를 확인할 수 있으며 실질적인 인증 로직을 수행하고, 성공 또는 실패에 대한 결과를 반환한다.
UserDetailsManager
/ UserDetailsService
는 DB/스토리지 시스템에서 UserDetail를 조회, 추가, 수정, 삭제하는데 도움을 준다.
보안 측면을 고려하여 비밀번호는 반드시 암호화 또는 해싱되어 저장되어야 한다.
Spring Security에서는 Password Encoder를 제공하여 유저의 자격 증명이 유효한지를 판별하고, 그 결과를 인증 관리자에게 전달한다.
인증이 성공하면 Spring Security 필터들은 9단계에서 Security Context에 인증 정보를 저장한다.
이로써, 첫 번째 로그인 이후에는 엔드 유저에게 자격 증명을 다시 요구하지 않는다.
보호된 자원에 접근하려는 엔드 유저에게 응답이 이루어진다.
이 응답은 10단계에서 이뤄지며, 엔드 유저에게 반환된다.
전체적인 흐름을 파악했다면 IntelliJ를 켜서 클래스와 인터페이스들을 확인해보자
Spring Security의 핵심 구성 요소 중 하나인 필터에 대한 설명이다.
인증, 권한 부여, 로그인 페이지 표시, 인증 정보 저장 등 다양한 역할을 수행한다.
이 중에서 Authorization 필터에 대한 간략한 설명을 해보려 한다.
엔드 유저가 접근하고자 하는 URL에 접근 제한
공개 URL은 자격 증명을 요구하지 않음
그러나 보안 URL에 접근 시 해당 요청을 Spring Security FilterChain의 다음 필터(=doFilter())로 리디렉트
doFilter() 메소드 내에서는 AuthorizationManager(권한 부여 관리자)의 도움을 받아 특정 URL이 공개 URL인지 보안 URL인지 체크한 후 그에 따라 URL에 접근을 허용하거나 URL 접근을 거부한다.
로그인 페이지 생성에 관여
보안 URL에 접근하려고 하면 로그인 페이지가 표시되었던 것을 말함
generateLoginPageHtml 메소드 내에서 로그인 페이지와 관련된 HTML 코드 생성
엔드 유저가 보안 URL에 접근 시 이 필터를 통해 로그인 페이지가 표시됨
인증을 위한 필터
attemptAuthentication 메소드에서 HTTP 출력 요청으로부터 userName와 password 추출
userName와 password 도움으로 UsernamePasswordAuthenticationToken 객체 생성 및 AuthenticationManager에게 전달
근데 여기서는 UsernamePasswordAuthenticationToken이라는 다른 객체가 보인다.
UsernamePasswordAuthenticationToken 클래스를 열면 AbstractAuthenticationToken을 상속 중이다.
AbstractAuthenticationToken 클래스를 열면 Autentication 인터페이스를 구현하고 있는 것을 확인할 수 있다.
Autentication은 인터페이스이기에 객체를 생성할 수 없어서 이를 구현한 UsernamePasswordAuthenticationToken이 객체를 생성하게 된다.
AuthenticationManager 또한 인터페이스
ProviderManager 클래스는 AuthenticationManager를 구현하여 사용
내부적으로 사용 가능한 모든 인증 제공자들을 반복하여 실행하며, ProviderManager는 인증 성공이나 인증 실패를 확인할 때까지 이 인증 제공자들을 반복
그러고 첫 번째 성공 시 종료
UserDetailsManager는 유저 정보 관리 인터페이스
Password Encoder는 비밀번호 암호화 또는 해싱을 수행하는 인터페이스
DaoAuthenticationProvider에서 InMemoryUserDetailsManager를 활용하여 기본적인 유저 정보 관리
브라우저를 통해 localhost:8080/welcome에 접근하는 플로우를 디버깅
Authorization 필터부터 시작하여 UsernamePasswordAuthentication 필터, ProviderManager, DaoAuthenticationProvider, UserDetailsManager 등을 확인
인증이 성공하면 성공 응답을 받게 됨
엔드 유저(End User): 웹 애플리케이션에 접근하려는 사용자.
Spring Security 필터: 보안 및 인증을 처리하는 Spring Security의 필터 체인.
ProviderManager: 인증 관리자의 구현으로, 모든 인증 제공자를 관리하며 시도합니다.
DaoAuthenticationProvider: 실질적인 인증을 수행하는 구현체로
UserDetailsManager와 PasswordEncoder를 활용합니다.
UserDetailsManager: 유저 정보를 제공하는 인터페이스 또는 구현체로,
InMemoryUserDetailsManager 등이 있습니다.
PasswordEncoder: 비밀번호를 처리하는데 사용되며, 암호화 및 해싱을 수행합니다.
엔드 유저 요청(API 경로 접근): 엔드 유저가 보안 또는 공개 API 경로에 접근하려고 함.
Spring Security 필터 실행: 요청이 Spring Security 필터에 도달하면
Authorization 필터 및 DefaultLoginPageGenerating 필터 등이 실행됨.
자격 증명 입력 페이지 표시: API 경로가 보안 API일 경우, 엔드 유저에게 자격 증명을 입력할 수 있는 로그인 페이지를 표시.
유저 로그인 및 UsernamePasswordAuthenticationFilter 실행: 엔드 유저가 로그인 정보를 입력하면 UsernamePasswordAuthenticationFilter가 해당 정보를 추출하고 UsernamePasswordAuthenticationToken 객체를 생성하여 ProviderManager에 전달.
ProviderManager의 Authenticate 메소드 호출: ProviderManager는 모든 인증 제공자를 시도하여 Authenticate 메소드를 실행. 이때, DaoAuthenticationProvider가 시도됨.
DaoAuthenticationProvider의 loadUserByUsername 메소드 실행: UserDetailsManager(InMemoryUserDetailsManager)를 통해 유저 정보를 불러옴.
비밀번호 일치 여부 확인: PasswordEncoder를 사용하여 입력된 비밀번호와 저장소 내의 비밀번호를 비교하고 일치 여부를 확인.
인증 성공/실패 응답: 비밀번호가 일치하면 ProviderManager에게 성공적인 응답을 전송하고, 그렇지 않으면 다음 가능한 인증 제공자를 시도함.
Spring Security 필터에 인증 정보 전송: ProviderManager가 성공적인 응답을 받으면 해당 정보를 Spring Security 필터에 전달.
인증 정보 저장 및 응답 반환: Spring Security는 인증 정보를 세션 및 보안 컨텍스트 객체에 저장하고, 엔드 유저가 다른 보안 API 경로를 호출하면 자격 증명을 다시 요구하지 않음.
Spring Security 프레임워크가 엔드 유저에게서 수신하는 다수의 요청을 유저의 자격 증명을 반복해서 요구하지 않고 처리하는 방법에 대해 알아보자.
이미 로그인된 상태에서 반복적으로 요청을 보내어 자격 증명을 다시 요구받지 않고 응답을 받는 것을 확인해보고자 한다.
먼저 이미 로그인한 상태에서 페이지를 리로드하면 자격 증명을 다시 요청받지 않고 즉시 응답을 받을 수 있음을 확인한다.
새로운 브라우저 세션을 시크릿 모드로 열어 동일한 페이지에 접근한다.
개발자 콘솔을 통해 JSESSIONID를 확인하고, 이를 통해 세션 관리 및 자격 증명을 이해한다.
유효한 자격증명(username:eazybytes, password:12345) 으로 로그인하면 새로운 JSESSIONID 값이 생성되고 브라우저로 반환
이 세션 ID는 Spring Security 프레임워크에 의해 생성되어 응답으로 브라우저로 전송되고 쿠키로 설정된다.
쿠키이기 때문에 본인 브라우저 탭에 남을 것이고, 같은 쿠키로 몇 번 요청을 하든 자격 증명을 요구받지 않고 응답을 바로 얻을 수 있다.
브라우저가 유효한 JESSIONID를 쿠키로 전송하는 한 Spring Sercurity는 오류를 제기하지 않고 자격증명 없이 요청이 성공적으로 처리되는 것을 허용
이 토큰을 변조하기 위해 쿠키의 앞에 글자 한개를 삭제하고 페이지를 새로고침하면 로그인 페이지로 돌아간다.
Spring Security가 유저 브라우저를 위해 기존에 생성하던 세션 URL 값이 변조되었기 때문에 해당 유저가 다시 로그인하도록 강제한다.
이것이 JESSIONID의 도움으로 가능한 Spring Security의 강점이다.
하지만 그닥 안전한 방법은 아니기 때문에 JWT 토큰을 이용한 고급 접근법을 추구에 다루고자 한다.