백엔드 서버 아키텍처 — Application Layer 2. Form 기반 Password 인증 with Spring Security

Junha Baek
junhabaek
Published in
22 min readApr 16, 2021

--

시리즈내의 다른 포스팅 목록은 → 이쪽에

· 이 포스팅에서는?

· 회원 가입 구현
✔ 회원 가입 Form 요청(GET)의 배경 지식
✔ 회원 가입 Form 요청(GET)의 구현
✔ 회원가입 요청(POST) 배경지식
✔ 회원가입 요청(POST) 구현

· 로그인 구현
✔ 로그인에 대한 배경지식.
✔ 로그인 Form 요청(GET)
✔ 로그인 요청(POST)의 배경지식
✔ 로그인 요청(POST)의 구현

· 샘플 서비스/컨트롤러 구현

· Spring Security 설정
✔ 기본 틀
✔ 인증 설정
✔ PasswordEncoder 설정
✔ static 경로 접근 허용

· 실습
✔ 미인증 상태에서, 인증 요구 리소스 접근
✔ 회원가입
✔ 로그인 폼 요청
✔ 로그인
✔ 인증 요구 리소스에 다시 요청한 결과

· 마치며

이 포스팅에서는?

인증의 사전 처리, 요청 주소에 따른 범용적인 인가 처리 등은, Presentation Layer에 도달하기도 전에 Filter에 의해 처리되는 경우가 많습니다. 하지만 게시물의 삭제를 할 때, 그 게시물의 소유주인지 체크하는 로직등의 경우에는 Filter에서 처리가 불가능하며, 어느 Layer는 이 역할을 담당해야 합니다.

앞선 Application Layer의 개요에서 살펴보았듯이 Application Layer는 트랜잭션 등 도메인과 무관한 애플리케이션 로직들을 처리하는 역할을 담당하고 있고, 필요한 Entity를 Retrieve하는 역할도 담당하고 있기 때문에, Application Layer에서 전반적인 인증/인가에 대해 설명하고 지나가겠습니다.

특히 이 포스팅에서는 Spring Security를 활용하는 인증 방법 중, 단순한 password 인증에 대해 설명합니다. 또한, 우선적으로 입력을 form-data로 받을 때의 처리를 설명하며, 이 다음 포스팅에서 JWT를 활용해, REST API에서의 Spring Security 적용 방법을 설명할 예정입니다.

유의 : 전반적인 인증을 설명하기 때문에, Application Layer를 벗어나는 구현/설명이 포함될 수 있습니다.

회원 가입 구현

웹 사이트의 form 기반 인증을 실제로 구현하면서, 인증과정에서 사용되는 Spring Security의 주요 요소들에 대해 설명해 드리겠습니다.

✔ 회원 가입 Form 요청(GET)의 배경 지식

먼저 여기서는 웹 페이지의 form을 입력하고, 서버에 대한 요청도 form-data 방식으로 들어온다고 가정하고 있습니다.

사용자가 회원가입 페이지에 접근하게 되면, 이 요청이 server주소/register에 mapping 되어있는 Controller의 메서드에 전달됩니다. 이 Controller는 해당 form의 상대적 위치+이름을 문자열의 형태로 반환합니다.

ViewResolver는 이 위치로부터 template(이때는 register.html)을 가져와 View 객체를 생성합니다.

이후 template이 렌더링 되면서, spring security는 csrf 공격을 방지하기 위해, html의 form에 csrf token이 주입합니다.

렌더링이 되면, 이 결과는 response에 포함이 되고, 사용자에게 반환됩니다.

✔ 회원 가입 Form 요청(GET)의 구현

이미 Spring을 어느정도 사용해보셨다면, 전반적인 흐름을 이해하실 수 있을 것입니다.

많은 부분들은 이미 spring이 자동으로 수행해야 하기 때문에, 회원가입 form을 반환함에 있어 우리가 해야 할 것을 간추려보자면 다음과 같습니다.

  1. 회원 가입 form을 전달하기 위해서는 이를 처리하는 Controller를 구현해야 합니다.
  2. 회원 가입 template을 만들어야 합니다.

여기에서는 Postman이나, SpringRunner를 이용해 사전 회원등록 처리를 할 예정이기 때문에, 회원가입 form을 사용하지 않을 예정이지만, Controller를 만들어본다면 대략 이런식으로 정의할 수 있습니다.

✔ 회원가입 요청(POST) 배경지식

사용자가 form을 받아 form-data를 전송했다고 가정하고 이후 흐름에 대해 설명드리겠습니다.

기본적으로 모든 요청들은 Servlet 필터를 거친 후, dispatcher servlet에 도달하게 됩니다.

여기에는 spring security가 기본적으로 등록하는 filter들인 springSecurityFilterChain이 있습니다. 이 필터들 중에는 아까 언급했던 csrf token을 검증하는 csrf filter가 있고, csrf filter를 비롯한 여러 filter들을 거치고 나면 dispatcher servlet으로 요청이 전달됩니다.

이후부터는 직접 구현한 회원가입 로직을 순차적으로 실행시켜야 합니다.

✔ 회원가입 요청(POST) 구현

여기서 직접 구현할 내용을 짚어보자면 다음과 같습니다.

  1. 요청 Mapping, Service호출을 담당하는 Controller 구현
  2. 생성로직 호출, Repository에 저장을 요청하는 Service 구현
  3. 생성의 대상이 되는 Account 정의, 도메인 서비스 구현
  4. 생성 대상을 저장하는 Repository의 구현

먼저 Controller를 만들어 봅시다.

여기서 요청 내용을 담는 Dto를 정의하고 있습니다. 유의 해야할점은 이 Dto의 형태가 결정되는 것은 Service의 측면에서 이루어져야 하며, Controller의 형태에 의해 이 Dto가 결정되어서는 안된다는 점입니다.

Service는 여러 Controller에 의해서 호출될 수 있기 때문에, 기본적인 의존성의 방향이 Controller → Service이지만, 만일 Service가 Controller에서 정의된 Dto에 의존하게 된다면 순환되는 의존성을 가지게 되는 문제가 발생합니다.

때문에, 요청 내용에 대한 Dto는 가급적 service layer(혹은 별도로 분리)에 두시는 것을 권장합니다.(응답 내용에 대한 dto라면 infra에 정의)

회원과 관련된 Dto들을 모아서 관리하기 위해, 내부 클래스를 활용합니다.

다음은 서비스의 구현입니다.

이전의 Application Layer의 개요에서 설명드렸던 대로, Service에서는 도메인 로직이 거의 들어있지 않기 때문에 구현이 매우 단순합니다.

여기에서는 Domain Service인 CreateAccountService에게 생성 책임을 전달하고 있습니다.

Domain Service에 대한 내용은 바로 이어지는 Domain Service 코드를 보면서 설명드리겠습니다.

다음은 도메인 로직의 구현입니다.

유저의 계정을 생성하는데 있어서 패스워드의 유출을 방지하고자 패스워드 인코딩이 필요합니다.

사실 Domain Layer의 설명이긴 하지만 살짝 맛보기만 하자면, 유저 생성 과정에 패스워드 인코딩이라는 도메인 로직을 포함시키는 방식은 2가지가 있습니다

먼저, Account를 생성하는 정적 팩토리 메서드의 매개변수로 PasswordEncoder를 포함하는 방식이 있습니다.

이 방식의 장점은 Account 생성 과정에 PasswordEncoder가 매개변수로 포함된만큼, 이 객체가 필요하다는 것을 외부에 알릴 수 있다는 점입니다. 그래서 개발자는 이 매개변수 형태(interface)만 보고 실수로 인코딩되지 않은 패스워드로 Account를 생성하는 일을 방지합니다.

하지만, 이 외부에 알릴 수 있다는 점의 다른면을 살펴보면 Account라는 Entity가 PasswordEncoder에 의존하고 있다는 점입니다. PasswordEncoder말고도 생성과정에 관여하게 되는 다른 클래스들이 많아질 수록, Entity가 외부에 대한 의존성을 너무 많이 가지게 될 수 있습니다.

이러한 점을 우려할 때, Domain Service를 이용해 어느정도 책임을 분리할 수 있습니다.

이 경우, CreateAccountService라는 Domain Service를 만들어, 이 객체가 PasswordEncoding에 대한 의존성을 가지고, Account 생성시에는 encrypted된 비밀번호를 전달합니다.

DDD에서는 로직을 수행하는데 필요한 정보를 가진 Entity가 로직을 수행하는것이 일반적이지만, 어느 특정 Entity의 역할이라고 말하기가 애매하거나, 불필요한 의존성을 주입한다는 생각이 든다면, Domain Service를 활용할 수 있습니다.(하지만 너무 남용하지 않도록 유의 필요)

하지만, 이 때 parameter 상으로 encrypted 되지 않은 비밀번호를 전달해도 등록이 되므로 사용에 있어서 유의가 필요합니다. 최소한의 조치로 생성시 전달되는 password Parameter의 이름을 encryptedPassword로 변경하는 것을 고려해볼 수 있습니다.

마지막으로 Repository의 구현입니다.

spring data jpa를 사용한다면 이 interface를 생성하기만 하면, repository의 기본 메서드를 사용할 수 있습니다.

Spring Data JPA 만세

로그인 구현

✔ 로그인에 대한 배경지식.

웹 환경에서 로그인을 생각할때는 2가지를 고려해야 합니다.

  1. 최초 웹 인증 방법
  2. 인증의 유지 방법

최초 웹 인증 방법은 말그대로 최초 사용자를 식별하기 위해 사용되는 방법들입니다. 최초 인증을 위해서 주로 username과 password가 사용되며, 이 정보들은 매 요청마다 보내질경우 보안상 우려사항들이 발생하기 때문에 별도의 인증 유지 방법을 함께 도입해야 합니다.

그 이유로는, username과 password가 암호화되어있다고 하나, 자주 반복될수록 원래의 암호를 추측할 가능성이 높아지며(현저히 낮긴 하지만), 클라이언트에 항상 username과 password가 저장되어 있어야 하며(chrome 내부 기능, 1password등의 솔루션이 있지만 이런 기능을 사용하지 않을 경우 문제가 발생합니다.), 인증 유지에 사용되는 session, jwt token과 같은 데이터들은 제한시간이 있고, 유출될 경우 ‘회수’가능한 데이터들이기 때문에 더 안전합니다.

먼저 최초 웹 인증 방법에는 주로 다음과 같은 방식들이 있습니다.

  • Form based Authentication
    html form태그로 인증 정보를 전송합니다.
    일반적으로 Spring에서 SSR을 수행하는 경우 이 인증 방식을 선택하게 됩니다.
    이 인증방식에 관여하는 Spring Security의 Filter는 UsernamePasswordAuthenticationFilter입니다.
    이 필터는 form data 방식만이 아니라, query string에서도 정보를 가져올 수 있기 때문에(query string은 parameter 취급이 되며 Username Filter에서는 getParameter를 사용해 정보를 추출합니다.), REST API에서도 활용될 수 있습니다.
  • Basic access Authentication
    아이디와 암호를 base64으로 암호화한 후 request header에 담아 보냅니다.
    Spring에서 API를 제공해야 할 때, 선택할 수 있는 방식입니다.
    API에서 Authentication을 하기 위해서 query string에 보내서 전달해도 되지만, API의 형식을 좀더 RESTful하게 유지하고 싶다면, Basic Authentication을 사용하게 됩니다.
    참고) https를 사용하면 query string도 암호화된 후 전송되어 안전하기 때문에, Basic Authentication을 사용하는 이유가 전적으로 보안적인 측면만 있는 것은 아닙니다.
    이 인증방식에 관여하는 Spring Security의 Filter는 BasicAuthenticationFilter입니다.
  • Digest Authentication
    비밀번호 대신 비밀번호를 뒤섞거나, 이의 축약형태(digest)를 송신합니다.
    일반적으로 Basic Authentication보다 덜 안전하기 때문에 사용되지 않는 듯 합니다.
    이 인증방식에 관여하는 Filter는 DigestAuthenticationFilter입니다.

다른 인증방식들도 있지만, Spring Security에는 위 3가지 인증 방식에 대한 필터를 기본적으로 등록하고 있습니다. 때문에, 어떤 방식으로 인증이 들어오더라도 그 중 하나에 의해 인증 정보가 성공적으로 만들어진다면(isAuthenticated) 그 이후부터는, 공통적인 인증 객체를 이용해서 이어지는 인증/인가 작업들을 수행할 수 있습니다.

참고) Post request의 body로 보내는 방법을 고려하실 수도 있지만, 이 방법은 표준적이지는 않기 때문에, 직접 Spring Security Filter를 구현해야 합니다.

✔ 로그인 Form 요청(GET)

로그인 form을 요청하는 방식도 회원가입 form을 요청하는 방식과 유사합니다.

하지만 사용자가 인증되지 않은 상태에서, 인증을 필요로 하는 리소스에 접근할 경우, 자동으로 로그인 화면으로 redirecting 해준다는 차이점이 있습니다.

로그인 요청도 postman으로 대체가 가능하나, redirecting을 고려한다면 form의 구성이 필요하므로, form template file을 프로젝트에 포함시키겠습니다.

이 코드는 spring 공식 문서에서 사용된 form입니다.

Controller는 회원가입처럼 단순하게 구성해주시면 됩니다.

만일 로그인 폼을 반환하는 컨트롤러를 SecurityConfig에서 지정하지 않았을 경우, DefaultLoginPageGeneratingFilter가 로그인 페이지를 자동으로 생성해줍니다.

✔ 로그인 요청(POST)의 배경지식

아마 로그인 요청이 가장 어려운 부분이지 않을까 생각합니다.

로그인 요청 과정을 핵심적인 부분만 따온다면 아래의 그림과 같습니다.

실제는 더 많은 필터와, 더 많은 객체들이 등장해야 하지만 핵심적인 내용만 담았습니다.

너무 오른쪽으로 길어서 보기가 힘드니, 반으로 잘라 순서대로 설명드리겠습니다.

전반부

  1. 사용자는 로그인 요청을 보냅니다.
    이 때, 로그인 컨트롤러를 직접 구현하지 않아도, SecurityConfig 상에 로그인 과정을 담당할 경로만 지정해두면, Spring Security내의 로그인 절차가 수행됩니다.
    default는 POST /login이기 때문에, 설정없이도 가능합니다.
  2. 앞서 회원가입에서 짧게 설명했듯이, dispatcherServlet에 도달하기전에 Servlet Filter들을 거치게 되어있고, 그중에서 springSecurityFilterChain이라는 FilterChain이 가진 필터들이 동작합니다. (Servlet Filter에 직접 등록된건 아닙니다. DelegatingFilterProxy에 의한 간접 참조입니다.)
  3. springSecurityFilterChain에는 여러 필터가 있지만, 로그인상에서 주목해야 할 필터는 SecurityContextPersistenceFilter, CsrfFilter, UsernamePasswordAuthenticationFilter 크게 3가지입니다. 이 중 첫번째 filter인 SecurityContextPersistenceFilter부터 필터링이 시작됩니다.(실제로는 두번째입니다. 첫번째는 맥락상 불필요해 설명 편의상 첫번째라고 가정합니다)

후반부

4. SecurityContextPersistenceFilter는 Session id가 있었다면, SecurityContext(인증 정보에 대한 Wrapper입니다.) 저장소인 SecurityContextRepository로부터(default 구현체는 이거) 기존 인증정보를 가져온 후, 이후의 필터들이 동작하도록 호출합니다.(기존 세션 정보가 없었다면 빈 세션을 생성합니다) SecurityContextPresistenceFilter의 doFilter가 호출될 때, 다음 수행되어야 할 필터에 대한 참조 내용을 받아왔기 때문에, chain.doFilter(request, response) 로 다음 필터를 호출할 수 있습니다.

5. 다음은 CsrfFilter가 호출되는데, 이 내용은 이전 회원가입 부분에서 설명했기에 생략합니다. 이 필터또한 다음 필터가 수행될 수 있도록 다음 필터를 호출합니다.

6. 그 다음, UsernamePasswordAuthenticationFilter가 수행되는데, 이 필터는 사용자가 보내온 request로부터 인증정보를 포함하는 Authentication 객체의 일종인 UsernamePasswordAuthenticationToken을 생성합니다. 이 때, request.getParameter를 호출하는데 form data를 받아올 수 있는 것으로 보아, spring mvc에서 @RequestParam annotation만으로 form data/querystring을 둘다 처리할 수 있는 맥락과 유사해 보입니다.(아마 security보다 앞선 필터들에서 request에 mapping 해줄 가능성 높음) username과 password가 들어간 Authentication 객체를 생성하면, AuthenticationManager에게 이 인증 정보에 대한 인증을 요청하게 됩니다.

7. AuthenticationManager의 구현체중에는 ProviderManager가 있으며, ProviderManager는 자신이 가진 Provider들 중, 이 authentication의 유형을 처리할 수 있는 Provider를 선택해 인증을 진행합니다.

8. Provider중 UsernamePasswordAuthenticationToken을 처리할 수 있는 Provider는 DaoAuthenticationProvider입니다. token안에는 사용자가 전달한 username과 password가 들어있습니다. 이 중 username을 사용하여 현재 저장되어 있는 user(Account)를 UserDetailsService에 요청합니다. 유저를 받아온 후 password일치 여부등, 유저를 검증하여 성공했다면 Authentication 객체를 반환하고 실패했다면 예외를 발생시킵니다.

9. Provider가 요청한 user를 받아오기 위해 loadByUsername 메서드가 호출됩니다.

10. AccountRepository에서 user를 받아옵니다.

  • SecurityContextPersistenceFilter는 이후의 필터동작들이 끝난 후, 다시 자신의 실행흐름으로 돌아와, 인증 완료된 Authentication 객체가 존재할 경우, 이를 SecurityContextRepository에 저장합니다.(기본 구현은 in memory입니다)
  • 세션 유지는 cookie를 이용한다. 사용자에게 sessionid가 set-cookie로 전달되므로, 사용자 브라우저에서는 이를 쿠키에 등록하고, 이후 요청할 때 쿠키와 함께 요청하게 된다.

✔ 로그인 요청(POST)의 구현

참 복잡한 내용들이 많죠? 하지만, 위에서 설명한 원리의 대부분은 이미 구현된 기능들이기 때문에 생각보다 직접 구현해야 하는 내용은 많지 않습니다.

여기에서는 설명은 Form based authentication으로 설명하지만, querystring에 담아 REST API로 호출하는 방식도 가능합니다.

여기에서 구현할 내용은 단 2가지입니다.

  1. AccountService에 loadByUsername구현
  2. Repository에서 구현.

DaoAuthenticationProvider에서 이 Service를 가져다 쓰기 위해서는 UserDetailsService를 구현해야 합니다. 이 인터페이스에는 loadByUsername하나만 있습니다.

이 때, loadByUsername은 UserDetails를 반환하도록 되어있는데요. 이를 위해 Account Entity를 감싸는 클래스를 다음과 같이 정의해야 합니다.

Account 자체가 UserDetails를 구현하도록 할 수 있지만, 인증/인가의 요구사항을 가지는 Account의 개념과, 우리가 웹 서비스에서 활용하는 Account의 개념을 분리하기 위해 별도로 정의합니다.

ROLE은 인가와 관련된 내용이므로 일단 무시해주세요.

spring data를 사용하신다면 repository의 구현은 메서드 정의 수준에서 끝입니다.

Account Entity에 username이라는 필드가 실제로 있는 경우에는 findBy뒤에 그 필드 이름(Username)을 붙여서 정의하면, 자동으로 그 메서드를 구현해줍니다.

간단한 기능의 경우에는 매우 유용하지만, 성능 최적화를 더 하고 싶거나, 복잡한 쿼리를 작성해야 하거나, 테스트 mocking을 고려하고 계신다면 직접 구현하시는 것을 권장드립니다.

querydsl을 이용해 구현한다면 위와 같은 코드가 될 수 있습니다.

샘플 서비스/컨트롤러 구현

이전 로그인에서 sessionid가 만들어졌고, 이것이 사용자의 클라이언트의 쿠키에 등록되었습니다. 이제 이 쿠키를 요청때마다 함께 보내면 서버에서는 이 쿠키(sessionid)를 통해, 사용자를 식별할 수 있습니다.

하지만, 회원가입 로그인만 구현하면, 로그인이 되었는지 안되었는지도 모르겠죠? 때문에, 인증이 적용되어야 할 서비스와 컨트롤러를 구현해봅시다.

다음 포스팅인 authorization에서 활용할 수 있도록, admin, general, anonymous service 요청을구분해서 만들어두겠습니다.

서비스는 단순히 유저의 이름을 로깅하는 기능만을 가집니다.

Spring Security 설정

Spring boot starter security를 포함시킨 후, 처음 프로젝트를 만들고 실행하면 default 인증이 수행됩니다.

하지만 지금은 프로젝트를 실행시켜도, 인증은 제대로 수행되지 않습니다.

설정은 직접 건들지 않았지만, 일부 Bean들의 생성 조건에는 특정 클래스의 Bean이 존재하지 않아야 한다는 조건을 가지고 있기 때문입니다.

설정 내용은 원리보다는 어떤 것들을 적용해야 하는지만 제시해드리도록 하겠습니다.

✔ 기본 틀

기본적으로 Security 설정을 할 때에는 WebSecurityConfigurerAdapter를 상속하여 만듭니다.

설정인만큼 Configuration annotation을 달아주셔야 하며, 기존의 설정을 대체하기 위해 EnableWebSecurity annotation도 달아줍니다.

✔ 인증 설정

설정을 할 때는, 필요한 configure 메서드를 override합니다.

HttpSecurity는 HTTP를 활용한 ‘인증’에 필요한 각종 설정들을 할 수 있습니다.

mvcMatchers를 이용해 url들을 지정하고 permitAll을 통해 해당 url들에 대한 접근 정책을 '허용'으로 설정합니다.

anyRequest는 위에서 언급되지 않은 나머지 모든 경로를 의미하며, 이들에 대해서는 authentication이 필요하다고 설정합니다.

여기서 postman을 이용해서 test 할 예정이기 때문에 csrf를 비활성화 시킵니다.

formLogin 설정을 통해, 로그인 페이지를 요청할 경로(GET)를 지정합니다.

✔ PasswordEncoder 설정

비밀번호 암호화를 위해 사용할 Encoder를 설정해주어야 합니다.

위 설정에 의해 BCrypt 기반의 PasswordEncoder가 적용됩니다.

✔ static 경로 접근 허용

현재 맥락에서는 필요가 없지만, css,js 파일을 제공할 경우 static 경로에 대해서 접근할 수 있도록 설정해야 합니다.

실습

✔ 미인증 상태에서, 인증 요구 리소스 접근

로그인페이지 반환

✔ 회원가입

문제 없이(200) 첫페이지로 돌아옴

✔ 로그인 폼 요청

로그인 폼 반환

✔ 로그인

성공(200)후, JSESSIONID 반환

✔ 인증 요구 리소스에 다시 요청한 결과

정상적으로 받아올 수 있었습니다.

마치며

이 포스팅에서는 Spring Security에서 인증의 동작 원리와 구현을 살펴보았습니다.

Spring Security는 별도의 Spring project로 분리될 만큼 아주 거대합니다. 때문에, 핵심 조감 후, 필요한 부분을 차근차근 학습하기를 권장드립니다.

다음 포스팅에서는 이 인증 내용을 기반으로 인가의 원리와 구현을 살펴보겠습니다.

다음 포스팅

참고

만약 여러분이 DDD 기반으로 앱을 만들고 계시다면, 조금더 깊은 고민이 필요할 수 있습니다. 자세한 내용은 아래 포스팅을 참고해주세요.

--

--