티스토리 뷰
(이전글)
https://tose33.tistory.com/1331
Spring Security
https://spring.academy/courses/building-a-rest-api-with-spring-boot/lessons/simple-spring-security Simple Spring Security - Building a REST API with Spring Boot - Spring Academy Learn the differences between authentication and authorization, and how Spring
tose33.tistory.com
스프링 아카데미가 제공하는 Spring security 부분을 정리.
- 목표
- 인증, 인가를 받은 유저만이 cash card api 경로에 접근할수 있다
- 올바른 사용자 (즉 해당 카드의 소유자) 만이 해당 카드에 접근할수 있다
1: Understand our Security Requirements
IF the user is authenticated // user 가 인증됐다면
... AND they are authorized as a "card owner" // ...AND "card owner" 로서 인가 됐다면
... ... AND they own the requested Cash Card // ... ... AND 요청이 온 신용카드를 소유한다면
THEN complete the users's request // THEN user의 요청을 완료하라
BUT don't allow users to access Cash Cards they do not own. // 하지만 user 가 소유하지 않는 신용카드에 대해서는 접근 불가능하게 하라
위의 조건을 스프링 시큐리티가 제공하는 기능들을 이용해 만족시키는것이 목표다.
2: Review update from Previous Lab
owner : 신용 카드를 만들고 관리가 가능한 사람
테이블 (schema.sql)
3: Add the Spring Security Dependency
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // See https://docs.gradle.org/8.3/userguide/upgrading_version_8.html#test_framework_implementation_dependencies
// 스프링 시큐리티
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.data:spring-data-jdbc'
testImplementation 'com.h2database:h2'
}
4: Satisfy Spring Security's Dependencies
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.build();
}
}
SecurityConfig.java 에서 스프링 시큐리티 관련 설정들을 해준다.
@Configuration 으로 해당 클래스를 설정 클래스로 만들고 SecurityFilterChain 을 반환하는 filterChain 을 @Bean 으로 빈으로 만들어 준다.
이전글에서 나와있듯 스프링 시큐리티는 Filter chain 을 기반으로 작동한다.
이렇게까지 해주면 아직 아무런 기능은 없지만 스프링 시큐리티의 기능들을 이용한 최저한의 세팅을 한 것이다.
5: Configure Basic Authentication (인증)
이제 스프링 시큐리티에서 인증을 어떻게하는지 알아보자.
이전글에서 아이디와 비밀번호를 credential 로서 사용하는 방식을 basic authentication 이라고 한다고 했다.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(request -> request
.requestMatchers("/cashcards/**")
.authenticated())
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
return http.build();
}
filter chain 을 basic authentication 을 사용하도록 설정해줬다.
"cashcards/" end point 로 오는 모든 요청은 basic authentication 을 사용해서 인증받아야 한다.
csrf(cross-site request forgery) 관련 보안은 disable 되있다.
6: Testing Basic Authentication
이제 filter chain 에서 설정한 보안이 작동되는지 테스트 해봐야 한다.
SecurityConfig.java
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
User.UserBuilder users = User.builder();
UserDetails sarah = users
.username("sarah1")
.password(passwordEncoder.encode("abc123"))
.roles() // No roles for now
.build();
return new InMemoryUserDetailsManager(sarah);
}
testOnlyUsers() 에서 테스트에 사용할 임의의 유저를 만들었다.
username 은 'sarah1' 이고, password 는 'abc123' 이다.
다음은 테스트를 수행할 CashCardApplicationTests.java 테스트 클래스다.
@Test
void shouldReturnACashCardWhenDataIsSaved() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("sarah1", "abc123")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
...
}
restTemplate 을 이용해 "/cashcards/99" 경로에 GET 요청을 보낸다.
그런데 .withBasicAuth() 를 통해 아이디와 비밀번호를 같이 보낸다.
이렇게 해줄 경우 아이디와 비밀번호 즉 credential 은 http header 에 포함되어 전송되고 서버는 해당 credential 이 "/cashcards/99" 에 접근할 권한이 있는지 확인할것이다.
설정클래스에서 username="sarah1", password="abc123" 의 테스트 유저를 만들었기 때문에 테스트는 성공한다.
***
참고로 여기서는 restTemplate 을 쓰고있는데 restTemplate 는 deprecated 됐고 더이상 사용을 권장하지 않는다.
이제는 WebClient 사용을 권장한다.
https://tose33.tistory.com/1305
Spring WebClient
WebClient 는 스프링이 제공하는 말 그대로 client 로서의 역할을 하기 위한 모듈이다. 동기 API 요청을 처리할때 사용한다. RestTemplate vs WebClient https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/spri
tose33.tistory.com
***
테스트
다음 테스트는 잘못된 credential 이 요청왔을때 HttpStatus.UNAUTHROIZED 를 반환해야 한다는 테스트다.
@Test
void shouldNotReturnACashCardWhenUsingBadCredentials() {
// 잘못된 username
ResponseEntity<String> response = restTemplate
.withBasicAuth("BAD-USER", "abc123")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
// 잘못된 password
response = restTemplate
.withBasicAuth("sarah1", "BAD-PASSWORD")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
7: Support Authorization (인가)
인증 설정을 했으니 이제 인가를 하는 방법이다.
스프링 시큐리티는 여러가지 방식의 인가를 지원하는데 가장 많이 사용하는 것은 Role-Based Access Controll (RBAC) 이다.
인증을 받은 여러 사용자로부터 서버에 요청이 올것인데, 오직 "card owner" 로서 인가를 받은 사용자만이 카드를 관리할수 있어야 한다.
위의 조건을 만족하도록 설정클래스의 filter chain 을 수정해보자.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(request -> request
.requestMatchers("/cashcards/**")
.hasRole("CARD-OWNER")) // Role 이 "CARD-OWNER" 이어야 한다!
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
return http.build();
}
"/cashcards/**" 경로로 오는 요청은
"CARD-OWNER" 의 Role 을 갖고 있어야만 접근할수 있다
테스트
테스트를 위해 설정 클래스의 이전에 만들었던 테스트 유저 sarah 이외에 한명을 더 추가한다.
@Bean
UserDetailsService testOnlyUsers(PasswordEncoder passwordEncoder) {
// sarah
User.UserBuilder users = User.builder();
UserDetails sarah = users
.username("sarah1")
.password(passwordEncoder.encode("abc123"))
.roles("CARD-OWNER") // new role
.build();
// hankOwnsNoCards
UserDetails hankOwnsNoCards = users
.username("hank-owns-no-cards")
.password(passwordEncoder.encode("qrs456"))
.roles("NON-OWNER") // new role
.build();
return new InMemoryUserDetailsManager(sarah, hankOwnsNoCards);
}
hankOwnsNoCards 라는 테스트 유저를 추가했다.
보면 sarah 는 "CARD-OWNDER" 라는 역할을 갖지만,
새로 추가된 hank 는 "NON-OWNER" 라는 역할을 갖는다.
테스트 코드는 다음과 같다.
@Test
void shouldRejectUsersWhoAreNotCardOwners() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("hank-owns-no-cards", "qrs456")
.getForEntity("/cashcards/99", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
"/cashcards/99" 경로에 hank의 credential 로 접근하고 있다. (99는 sarah 의 카드다. 뭔가 이상하지만 우선 넘어가자)
하지만 우리는 설정 클래스에서 "cashcards/**" 의 경로는 오직 "CARD-OWNDER" 의 Role 을 갖는 사용자만이 접근할수 있다고 설정했으므로 HttpStatus.FORBIDDEN 을 리턴 받고 테스트는 성공한다.
여기까지 하면 RBAC Authorization 설정을 완료한 것이다.
그런데 지금 보안에 아주 큰 구멍이 있다.
현재 설정 클래스 설정은 "cashcards/**" 경로에는 "CARD-OWNER" 의 Role 을 갖는 사람들은 모두 접근할수 있다.
즉 "CARD-OWNER" 의 Role 을 갖고 있기만 하면 다른 사람의 카드에도 접근할수 있는 것이다.
예를들어 바로 위 테스트에서 hank 가 "/cashcards/99" 경로에 접근하다가 실패했는데 이는 hank 의 role 이 "NON-ONWER" 이기 때문이다.
만약 hank 의 role 을 "CARD-OWNER" 로 바꾼다면 hank 는 sarah 가 소유하는 "/cashcards/99" 경로에 접근할수 있게된다.
8: Cash Card ownership: Repository Updates
이제 목표는 "CARD-OWNER" 의 Role 을 갖더라도 소유자가 아니라면 접근할수 없도록 하는 것이다.
CashCardRepository 인터페이스
interface CashCardRepository extends CrudRepository<CashCard, Long>, PagingAndSortingRepository<CashCard, Long> {
CashCard findByIdAndOwner(Long id, String owner);
Page<CashCard> findByOwner(String owner, PageRequest pageRequest);
}
CrudRepository, PagingAndSortingRepository 를 상속받고 있는데 이 둘은 모두 Spring Data 의 일부다.
이것들은 나도 제대로 다뤄본적은 없는데 이들의 내부에는 save, findById, findAll, delete 와 같은 CRUD 쿼리들이 작성되어 있다.
그래서 이들을 상속받고 메소드의 이름만 정의된 대로 만들어주면 따로 쿼리문을 작성하지 않아도 CRUD 를 수행할수 있다.
예를들어 findByFirstName(String firstName); 이런 식으로 이름이 정해져 있다.
9: Cash Card ownership: Controller Updates
이제 컨트롤러를 수정한다.
CashCardController.java
@RestController
@RequestMapping("/cashcards")
class CashCardController {
...
@GetMapping("/{requestedId}")
private ResponseEntity<CashCard> findById(@PathVariable Long requestedId) {
// findByIdAndOwner
Optional<CashCard> cashCardOptional =
Optional.ofNullable(cashCardRepository.findByIdAndOwner(requestedId, principal.getName()));
if (cashCardOptional.isPresent()) {
return ResponseEntity.ok(cashCardOptional.get());
} else {
return ResponseEntity.notFound().build();
}
}
...
@GetMapping
private ResponseEntity<List<CashCard>> findAll(Pageable pageable, Principal principal) {
// findByOwner
Page<CashCard> page = cashCardRepository.findByOwner(principal.getName(),
PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
pageable.getSortOr(Sort.by(Sort.Direction.ASC, "amount"))
));
return ResponseEntity.ok(page.getContent());
}
...
}
기존의 findById() 메소드에서 를 findByIdAndOwner() 로 바꿔줬다.
Principal 은 스프링 시큐리티가 제공하고 컨트롤러에서 사용할수 있다 객체다.
- Principal 이란 인증,인가를 받아야하는 리소스 사용자를 나타낸다
- Principal 에 사용자의 authentication, authorization 정보가 담겨있다
principal.getName() 은 basic auth 가 제공하는 username 을 리턴한다.
테스트
data.sql
@Test
void shouldNotAllowAccessToCashCardsTheyDoNotOwn() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("sarah1", "abc123")
.getForEntity("/cashcards/102", String.class); // kumar2's data
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
"/cashcards/102" 는 kumar2 의 카드이므로 NOT_FOUND 를 리턴할것이다.
즉 테스트는 성공한다.
10: Cash Card ownership: Creation Updates
아직 보안의 허점이 하나 남아있다.
아래는 CashCardController 의 createCashCard() 메소드고, 새로운 신용카드를 생성하는 로직이다.
@PostMapping
private ResponseEntity<Void> createCashCard(@RequestBody CashCard newCashCardRequest,
UriComponentsBuilder ucb) {
CashCard savedCashCard = cashCardRepository.save(newCashCardRequest);
URI locationOfNewCashCard = ucb
.path("cashcards/{id}")
.buildAndExpand(savedCashCard.id())
.toUri();
return ResponseEntity.created(locationOfNewCashCard).build();
}
여기서 문제는 newCashCardRequest 에 담겨있는 owner 를 기반으로 새로운 신용카드를 만들고 있다는 것이다.
즉 이대로라면 sarah 가 hank 의 카드를 만들수도 있게된다.
아래와 같이 인증, 인가가 완료된 Principal 을 사용해 카드를 만들어야 한다.
principal.getName() 으로 Principal 에 담겨있는 username 을 꺼내서 새로운 객체를 만들어서 저장하고 있다.
@PostMapping
private ResponseEntity<Void> createCashCard(@RequestBody CashCard newCashCardRequest,
UriComponentsBuilder ucb, Principal principal) {
// owner를 갖는 새로운 CashCard 를 만든다
CashCard cashCardWithOwner =
new CashCard(null, newCashCardRequest.amount(), principal.getName());
// owner 를 갖는 CashCard 를 저장!
CashCard savedCashCard = cashCardRepository.save(cashCardWithOwner);
URI locationOfNewCashCard = ucb
.path("cashcards/{id}")
.buildAndExpand(savedCashCard.id())
.toUri();
return ResponseEntity.created(locationOfNewCashCard).build();
}
11: About CSRF
CashCardConfig.java
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(request -> request
.requestMatchers("/cashcards/**")
.hasRole("CARD-OWNER")) // Role 이 "CARD-OWNER" 이어야 한다!
.csrf(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults());
return http.build();
}
설정 클래스에 현재는 CSRF 보안을 비활성화 해놨다.
스프링 시큐리티 팀의 조언은 다음과 같다:
CSRF 는 클라이언트가 보통 유저 즉 웹 브라우저일때 킨다.
만약 클라이언트가 non-browser 이라면 (서버끼리 통신하는것 같은 경우) 끄는것이 좋다.
CSRF 관련 링크:
https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/csrf.html
Testing with CSRF Protection :: Spring Security
When testing any non-safe HTTP methods and using Spring Security’s CSRF protection, you must include a valid CSRF Token in the request. To specify a valid CSRF token as a request parameter use the CSRF RequestPostProcessor like so:
docs.spring.io
'Web > Spring Security' 카테고리의 다른 글
로그인 성공 후 로직 처리, AuthenticationSuccessHandler (0) | 2024.01.15 |
---|---|
Spring Security 의 OAuth2 (0) | 2023.12.20 |
Spring Security 구조 (0) | 2023.12.11 |
Spring Academy) Simple Spring Security (0) | 2023.12.10 |
- Total
- Today
- Yesterday
- priority queue
- dfs
- Spring
- 이분탐색
- binary search
- two pointer
- greedy
- floyd warshall
- Unity
- CSS
- Stack
- 자료구조
- Implementation
- Brute Force
- recursion
- MVC
- C
- 조합
- Dijkstra
- Python
- BFS
- permutation
- Tree
- Kruskal
- C++
- graph
- 재귀
- back tracking
- DP
- db
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |