Spring Security를 적용한 EasyBank 애플리케이션을 제작해보자.
사용자가 계좌, 잔고, 대출 정보를 확인하고 관리할 수 있는 은행 애플리케이션이다.
EasyBank의 로그인 페이지에서 사용자는 이메일과 비밀번호를 입력하여 로그인할 수 있다.
로그인 후에는 대시보드 페이지로 이동하게 된다.
대시보드 페이지에서는 계좌 정보, 잔고 정보, 대출 정보, 카드 정보 등을 확인할 수 있다.
사용자의 주요 금융 정보를 한 눈에 파악할 수 있는 중요한 페이지이다.
EasyBank는 계좌, 잔고, 대출, 카드 정보를 별도의 페이지에서 제공한다.
< 계좌 페이지 > < 잔고 페이지 > < 대출 페이지 > < 카드 정보 페이지 >각 페이지에서는 해당하는 정보에 대한 상세한 내용을 확인할 수 있다.
사용자는 문의하기 페이지에서 은행에 질문이나 요청을 할 수 있다.
이 페이지는 공개 페이지로, 자격 증명이 필요하지 않는다. 계좌 개설, 대출 신청 등의 모든 Use Case에 사용된다.
공지사항 페이지에는 은행에서 발행하는 모든 공지사항이 게시된다.
혜택, 서비스 점검, 신용카드 출시 등 다양한 정보를 확인할 수 있는 중요한 페이지이다.
EasyBank는 Angular 프레임워크를 기반으로 개발되었다.
별도의 UI 애플리케이션을 통해 Spring boot 백엔드와 통신하며, 이로써 보안 위험성 및 보안 공격에 대한 예시도 제공한다.
이것이 최근에 보이는 일반적인 개발 유형이다. 대부분 별도의 UI 프로젝트가 있는 풀스택 개발을 채택하고 있다. 이 UI 애플리케이션들은 백엔드 코드와 함께 Rest 서비스에 연결된다.
REST 서비스와 Spring Boot 웹 애플리케이션을 활용하여 개발할 서비스 목록에 대해 정리한다.
/contact 서비스: 문의하기 페이지를 통해 은행에 연락하고자 할 때 사용되며, Angular 애플리케이션에서 입력값을 받아 데이터베이스에 저장합니다.
/notices 서비스: 백엔드 데이터베이스에서 공지 내용을 불러와 Angular UI 애플리케이션의 공지사항 페이지에 표시합니다.
/myAccount 서비스: 유저 정보를 불러와 계좌 정보를 UI에 표시합니다.
/myBalance 서비스: 잔고 정보를 불러와 UI에 표시합니다.
/myLoans 서비스: 대출 정보를 불러와 UI에 표시합니다.
/myCards 서비스: 카드 정보를 불러와 UI에 표시합니다.
물론 등록 서비스, 유저 로그인 서비스와 같은 다른 서비스들도 개발할 것이다.
이것들은 나중에 진행하고, 지금은 이 서비스들이 Spring Boot 웹 애플리케이션에 개발해야 할 주된 비즈니스 서비스들이다.
이전에 만든 프로젝트를 복사해서 새로운 프로젝트를 생성한다.
새 프로젝트의 이름은 'springsecsection2'로 수정한다. 이는 해당 프로젝트가 섹션 2에서 개발될 것을 나타낸다.
새로운 프로젝트의 pom.xml을 열어 artifact ID와 name을 springsecsection2'로 수정한다.
메인 폴더의 Java 폴더 안에 있는 기존의 controller와 Spring Boot 메인 애플리케이션 클래스를 복사하고 새로운 프로젝트로 붙여넣는다.
패키지 이름을 'com.easybytes.springsecsection2'로 수정한다.
컨트롤러 클래스와 메인 애플리케이션 클래스의 이름을 'WelcomeController'에서 'EasyBankBackendApplication'으로 수정한다.
IntelliJ IDEA에서 'New - Module from Existing Sources'를 선택하여 'springsecsection2' 프로젝트를 업로드한다.
프로젝트를 컴파일하면서 발생한 이슈를 해결한다.
필요 없는 컨트롤러 클래스를 삭제하고, 테스트 폴더의 패키지 및 클래스를 수정한다.
컴파일 완료 후, Maven을 통해 필요한 의존성을 다운로드한다.
AccountController 클래스를 생성하고 /myAccount 경로로 접근할 수 있는 REST 서비스를 개발
현재는 단순한 문자열 메시지를 반환하며, 나중에 데이터베이스로부터 유저 정보를 불러오는 기능을 추가할 예정
package com.eazybytes.springsecsection2.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class AccountController {
@GetMapping("/myAccount")
public String getAccountDetail() {
return "Here are the account details from the DB";
}
}
BalanceController 클래스를 생성하고 /myBalance 경로로 접근할 수 있는 REST 서비스를 개발
현재는 단순한 문자열 메시지를 반환하며, 거래 내역과 잔고 정보를 반환하는 기능을 나중에 추가할 예정
package com.eazybytes.springsecsection2.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BalanceController {
@GetMapping("/myBalance")
public String getBalanceDetails() {
return "Here are the balance details from the DB";
}
}
LoansController, CardsController, ContactController, NoticesController 클래스를 각각 생성하고, 각각의 클래스에서 새로운 REST 서비스를 정의
현재는 단순한 문자열 메시지를 반환하며, 나중에 각 서비스에 필요한 데이터를 동적으로 반환할 수 있도록 개발할 예정
package com.eazybytes.springsecsection2.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LoansController {
@GetMapping("/myLoans")
public String getLoanDetails() {
return "Here are the loan details from the DB";
}
}
package com.eazybytes.springsecsection2.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CardsController {
@GetMapping("/myCards")
public String getCardDetails() {
return "Here are the card details from the DB";
}
}
package com.eazybytes.springsecsection2.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ContactController {
@GetMapping("/contact")
public String saveContactInquiryDetails() {
return "Inquiry details are saved to the DB";
}
}
package com.eazybytes.springsecsection2.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class NoticesController {
@GetMapping("/notices")
public String getNotices() {
return "Here are the notices details from the DB";
}
}
생성한 서비스들을 확인하기 위해 애플리케이션을 실행
Spring Security 프레임워크에 의해 모든 서비스가 보호되어 있어, 각 서비스에 접근하려면 로그인이 필요하다.
이전에는 Spring Boot 웹 애플리케이션에서 Spring Security를 사용하여 기본적으로 모든 REST API가 보호되는 것을 살펴보았다.
하지만 특정 URL은 보호되어야 하고 나머지는 보호되지 않도록 만들어야 한다.
따라서 Spring Security 프레임워크에서 모든 URL을 디폴트로 보호하는 방법을 이해하기 위해 SpringBootWebSecurityConfiguration 클래스의 코드를 살펴보자.
Maven이 dependencies를 다운로드하려고 할 때 소스코드, 문서, 주석, 필요한 모든 것을 함께 다운로드 할 수 있도록 돕는다.
SpringBootWebSecurityConfiguration 클래스에는 defaultSecurityFilterChain이라는 메소드가 있다.
이 메소드는 HttpSecurity를 매개변수로 받아와서 모든 URL을 디폴트로 보호하는 코드를 포함하고 있다.
그리고 이 HttpSecurity를 사용해 authorizeHttpRequests()라는 메소드를 호출하고 있다. 그로써 웹에 들어온 모든 요청(anyRequest())이 인증(authenticated())되어야 함을 나타낸다.
바로 이 부분이 Spring Boot 웹 애플리케이션에 들어오는 모든 요청을 보호하는 핵심이다.
@Configuration(proxyBeanMethods = false)
class SpringBootWebSecurityConfiguration {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorizeRequests ->
authorizeRequests
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.httpBasic(withDefaults());
return http.build();
}
// 다른 내용 및 주석 생략
}
모든 요청을 보호하고, HTML 형태의 요청은 formLogin을 통해, REST API 호출은 httpBasic을 통해 인증을 받도록 설정하고 있다.
이러한 디폴트 동작을 무시하고 나만의 맞춤 보안 요구사항을 정의하는 방법에 대해 다뤄보자
이를 위해 config 패키지를 생성하고 그 안에 ProjectSecurityConfig 클래스를 만들었다.
이 클래스에서는 SecurityFilterChain 빈을 정의하여 우리만의 보안 설정을 추가할 수 있게 된다.
기본적인 세팅을 해놓기 위해 SpringBootWebSecurityConfiguration 클래스의 defaultSecurityFilterChain() 메소드를 그대로 가져왔다.
package com.eazybytes.springsecsection2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ProjectSecurityConfig {
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((requests) -> {
((AuthorizeHttpRequestsConfigurer.AuthorizedUrl)requests.anyRequest()).authenticated();
});
http.formLogin(Customizer.withDefaults());
http.httpBasic(Customizer.withDefaults());
return (SecurityFilterChain)http.build();
}
}
Spring Security의 SecurityFilterChain bean을 활용하여 맞춤형 보안 설정을 정의하는 방법을 알아보자.
현재 Spring Boot 웹 애플리케이션에는 총 6개의 다른 API가 있으며, 그 중 4개(API: myAccount, myBalance, myLoans, myCards)는 보호되어야 하고, 나머지(API: 문의하기, 공지사항)는 누구나 호출할 수 있도록 보호되지 않아야 한다.
맞춤 보안 설정을 정의할 때는 requestMatches() 메소드를 활용한다.
이 메소드는 무제한의 API 경로를 받아들이며, 특정 API는 authenticated() 메소드를 호출하여 보호하거나, permitAll() 메소드를 호출하여 보호하지 않도록 설정한다.
이로써 각 API에 대한 맞춤 보안 설정을 쉽게 정의할 수 있다.
참고로 Spring Security 6.1과 Spring Boot 3.1.0 버전을 시작으로 람다(Lambda) DSL 스타일 사용을 권장한다.
Customizer.withDefaults()는 Spring Security에서 제공하는 설정에 대한 기본값을 사용하도록 하는 메서드이다.
HTML 형태의 요청은 formLogin을 통해, REST API 호출은 httpBasic을 통해 인증을 받도록 설정할 때 위 메서드를 기본값으로 세팅하여 사용한다.
package com.eazybytes.springsecsection2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ProjectSecurityConfig {
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
//람다(Lambda) DSL 스타일 사용을 권장
http.authorizeHttpRequests((requests) -> requests
.requestMatchers("/myAccount","/myBalance","/myLoans","/myCards").authenticated() //보호되길 원함
.requestMatchers("/notices","/contact").permitAll()) //누구든 접근 가능
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
문제가 발생한 경우, ComponentScan을 제거하고 Spring Boot가 모든 하위 패키지를 스캔하도록 설정한다.
이를 통해 맞춤형 보안 설정이 정상적으로 생성될 수 있다.
API 호출을 통해 설정이 제대로 동작하는지 테스트하고, 문제가 발생할 경우 디버깅을 통해 원인을 찾는다.
로그인을 하든 안 하든 "/notices","/contact" 중 하나로 url을 접근하면 요청한 데이터가 출력된다.
하지만 로그인을 하지 않고 "/myAccount","/myBalance","/myLoans","/myCards" 중 하나로 url을 접근하면 인증을 요구하는 로그인 페이지가 나오고, 로그인 후 재접근 하면 요청한 데이터가 출력되는 것을 확인할 수 있다.
Spring Security에서 제공하는 denyAll() 메소드를 사용하여 웹 애플리케이션에 오는 모든 요청을 거부하는 방법에 대해 알아보자.
이러한 기능은 특수한 요구사항에 대응하기 위해 사용될 수 있다.
보안 테스트 및 유스케이스 테스트: 클라이언트로부터의 모든 요청을 거부하여 보안 테스트를 수행하거나 특정 시나리오에 대한 유스케이스 테스트를 진행할 수 있다.
하위 환경 구성: 특정 환경(개발, 테스트 등)에서는 모든 요청을 거부하고, 운영 환경에서만 실제 사용자 정의 보안을 적용하고자 할 때 유용하다.
Spring Boot 프로필을 사용하여 환경에 따라 조건부로 Bean(빈)을 생성할 수 있다.
이를 통해 특정 환경에서만 설정을 적용할 수 있다.
package com.eazybytes.springsecsection2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ProjectSecurityConfig {
//람다(Lambda) DSL 스타일 사용을 권장
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
/**
* 사용자 정의 보안 설정
*/
// http.authorizeHttpRequests((requests) -> requests
// .requestMatchers("/myAccount","/myBalance","/myLoans","/myCards").authenticated() //보호되길 원함
// .requestMatchers("/notices","/contact").permitAll()) //누구든 접근 가능
// .formLogin(Customizer.withDefaults())
// .httpBasic(Customizer.withDefaults());
// return http.build();
/**
* 모든 요청을 거부
*/
http.authorizeHttpRequests((requests) -> requests
.anyRequest().denyAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
주의: 이러한 설정은 운영 환경에서는 사용하지 않는 것이 좋다.
모든 요청을 거부하면 애플리케이션이 쓸모 없어지므로 주의가 필요하다.
브라우저에서 API 중 하나인 /myAccount에 접근하면 자격 증명을 요구하는 화면이 나타난다.
그 외 유효한 자격 증명을 입력해도 403 에러가 반환되는 것을 확인할 수 있다.
이와 같이 모든 API에 대해 요청이 거부된다.
해당 설정은 보안 테스트나 특정 환경에서의 테스트를 위해 사용되며, 실제 운영에서는 신중하게 적용해야 한다.
웹 애플리케이션에서 모든 API에 대한 접근을 허용하도록 설정해야할 때가 있습니다.
이는 denyAll과는 반대로 모든 요청을 거부하는 대신, 모든 요청을 허용하는 시나리오이다.
주로 개발 및 테스트 환경에서 보안을 강화하지 않고 편리하게 접근할 수 있도록 요청하는 경우에 사용된다.
앞서 설명한대로 두 가지 다른 SecurityFilterChain @Bean을 생성하여 하나는 운영 환경(=permitAll())에서, 다른 하나는 비운영 환경(=denyAll())에서 활성화되도록 설정한다.
package com.eazybytes.springsecsection2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class ProjectSecurityConfig {
//람다(Lambda) DSL 스타일 사용을 권장
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
/**
* 사용자 정의 보안 설정
*/
// http.authorizeHttpRequests((requests) -> requests
// .requestMatchers("/myAccount","/myBalance","/myLoans","/myCards").authenticated() //보호되길 원함
// .requestMatchers("/notices","/contact").permitAll()) //누구든 접근 가능
// .formLogin(Customizer.withDefaults())
// .httpBasic(Customizer.withDefaults());
// return http.build();
/**
* 모든 요청을 거부
*/
// http.authorizeHttpRequests((requests) -> requests
// .anyRequest().denyAll())
// .formLogin(Customizer.withDefaults())
// .httpBasic(Customizer.withDefaults());
// return http.build();
// }
/**
* 모든 요청을 허용
*/
http.authorizeHttpRequests((requests) -> requests
.anyRequest().permitAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
주의: permitAll 설정은 주로 개발 및 테스트 환경에서 사용된다.
운영 환경에서는 신중하게 적용해야 하며, 클라이언트가 사용자 정의 보안 설정을 적용하고자 할 때는 해당 설정을 운영 환경에 맞게 조정해야 한다.
해당 설정을 적용하면 모든 API에 대한 접근이 허용된다.
브라우저에서 공지사항, 문의하기, myAccount 등 모든 API에 대해 접근을 테스트하여 응답이 정상적으로 반환되는지 확인할 수 있다.