티스토리 뷰
Vue.js와 Spring Boot를 사용 해 진행중인 나의 프로젝트에서 로그인 시스템을 만들려던 중 Spring Boot에서 인증/인가를 구현하기 위한 프레임워크인 Spring Security에 대해서 알게되었다.
하지만 단순히 의존성 추가하고 Configure, Annotation 몇개 추가한다고 되는게 아니였다. 생각보다 복잡한 구조를 가지고 있었고 공부를 위해 찾아봤던 자료들도 난이도가 있어 익히기 쉽지 않았다.(다행히 나만 어렵다고 느낀건 아니였다...)
차근차근 정리하면서 프로젝트에 JWT를 사용한 토큰인증방식을 구현하는 것까지 가보려고 한다.
Spring Security란?
Spring Security는 Spring 기반의 애플리케이션에서 보안(인증과 권한, 인가)을 처리해주는 Spring 하위 프레임워크라고 한다. 이러한 소개에서 느낄 수 있듯이 내가 사용하려는 것은 Spring Security 프레임워크에서 극히 일부에 불과하다.
위 사진은 Spring Security의 인증관련 구조다. 인증하나 하려고 이런과정을 거쳐야한다고..? 라고 생각할 수 있다. 나도 처음엔 그냥 인자로 넘어온 아이디/비밀번호 디비 정보랑 비교하고 True, False 돌려보내면 되는거 아니야? 라고 생각했기 때문이다.
사실 위 사진을 이해하는 것 자체가 머리아프다... 설명해놓은 글을 보는 것도 머리아픈일이다. 우연히 유튜브를 찾던 중 훨씬 직관적이고 이해할만한 내용이 있어 직접 그려봤다.
Security Context와 같이 인증된 객체를 관리하는 관리자(SecurityContextHolder) 등이 추가적으로 존재하지만 인증은 위 흐름을 기본적으로 따른다고 생각하면 편할듯 하다.
(사실 다양한 케이스를 만나본게 아니기 때문에 이 외의 흐름이 분명 존재할 수 있다)
Filter Chain
HTTP Request가 서버로 왔을 때 WebSecurityConfig를 상속받은 SecurityConfig에서 추가되어있는 Filter가 있다면 해당 Filter에 의해서 인증과정을 거치게 된다.
여러 Filter가 Chain모양으로 순차적으로 묶여있기 때문에 Filter Chain이라고 부른다. 어렵게 생각할 것 없이 여러가지 인증을 동시에 적용할 수 있고 이러한 하나의 단위를 Filter라고 생각하면 될듯하다. 조금 더 그럴싸하게 말하면 인증의 의미(Flow)단위별로 Filter를 구현하면 된다.
Filter Chain에 추가할 수 있는 Filter에는 기본적으로 Spring Security에서 제공하는 다양한 Filter들(위 이미지 참고)이 있고, 사용자가 커스터마이징한 Filter를 생성해 사용할 수도 있다.
그러면 사용자가 커스터마이징한 Filter에 대한 예시를 살펴보자.
기본적으로 커스터마이징하기 위한 Filter는 AbstractAuthenticationProcessingFilter 클래스를 상속받으면 된다. 이외에 다른 Filter 클래스를 상속받아 작성해도 되지만.. 아직 무슨 차이인지 모르겠다.
그럼 Filter 입장에서 기본적인 흐름이 어떻게 되는지 살펴보자.
3가지 메서드를 Override하면 되는데 attemptAuthentication 메서드는 HTTP Request가 처음으로 들어오는 메서드이고, successfulAuthentication/unsuccessfulAuthentication 메서드는 Provider에 의해서 인증과정을 거치고 인증의 성공/실패 여부에 따라서 호출되는 메서드이다.
- attemptAuthentication 메서드에서는 Request Body에 있는 데이터를 DTO 객체로 변환한 후 Provider Manager의 authentication 메서드에 전달하게 된다.
- authentication 메서드의 결과가 성공이라면 인증된 객체가 successfulAuthentication 메서드로 돌아오고, 발행된 토큰과 같은 적절한 데이터를 포함한 HTTP Response를 돌려주면 된다.
- authentication 메서드의 결과가 실패라면 인자로 넘어온 예외를 활용해 사용자에게 적절한데이터를 보여줘 이후 프로세스를 진행할 수 있도록 하면 된다.
Authentication 객체
인증객체란 Authentication 인터페이스를 구현한 모든 클래스의 객체를 말한다.
일반적으로 Authentication 인터페이스를 직접 구현하는 것이 아니라 Spring Security에서 이미 구현해놓은 클래스를 상속받아 사용하는 것 같다. 왜냐하면, Authentication 인터페이스에는 구현해야할 6개의 메서드가 있는데 이를 다 직접 구현하는건 번거롭기 때문이다.
예를들어, Spring Security에서 제공하는 UsernamePasswordAuthenticationToken 클래스가 있다. 더 다양한 클래스가 있겠지만 내가 구현한 시스템에서는 해당 클래스를 사용한다.
이러한 Authentication 인터페이스에는 어떠한 메서드들이 포함되어 있는지 살펴보자.
- Collection<? extends GrantedAuthority> getAuthorities() // 사용자의 권한 목록
- Object getCredentials() // 주로 Password
- Object getDetails()
- Object getPrincipal() // 주로 ID
- boolean isAuthenticated() // 인증여부
- void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException
각 메서드에서 기억해야되는 부분 몇 가지만 정리해보자.
Collection<? extends GrantedAuthority> getAuthorities()
GrantedAuthority를 상속받은 객체의 배열이다. 얘가 추가된 생성자를 호출할 때는 AuthenticationManager, AuthenticationProvieder를 통해 인증과정을 마친 객체여야 한다. 즉, 생성자를 호출할 때 Authorities(세 번째 인자)가 포함되어있다면 인증받은 객체라 생각해도 좋다.
내가 구현한 예제에서는 GrantedAuthority 인터페이스를 구현한 SimpleGrantedAuthority 클래스로 List를 만들어 사용한다. 여기서 사용한 SimpleGrantedAuthority 클래스는 String role 인스턴스 변수를 사용하는 간단한 클래스이다.
(문자열로 구성된 간단한 권한을 처리할 때 사용하기 좋을듯하다)
Object getCredentials()
Credential은 자격증명이라는 뜻으로 보통 패스워드와 같이 인증에 사용되는 정보를 나타낼때 사용된다. 내가 구현한 예제에서는 String형 객체에 패스워드를 저장해 사용한다.
Object getPrincipal()
Principal은 해당 인증객체를 식별하기 위한 용도로 사용된다. AccountContext 객체를 통으로 넣을수도 있고 String형 객체에 아이디를 저장해 사용할수도 있다. 간단하게 해당 사용자를 고유하게 식별할 수 있는 객체가 들어가는 공간이라고 이해하면 좋을 것 같다.
여기서 나오는 AccountContext 객체에 대해서는 밑에서 한번더 언급할 예정이다.
boolean isAuthenticated()
해당 메서드는 직접 호출할 일은 없지만 인증요청 객체와 인증된 객체를 구분하기 위해 사용되는 메서드라는 것은 알아둬야 한다. 즉, Provider의 authenticate() 메서드를 통해 인증과정을 거쳤다면 True, 인증과정을 거치기 전이라면 False를 반환하는 메서드이다.
그러면 예제를 구현할 때 사용한 2가지 클래스를 살펴보자.
보는 것과 같이 두 개의 클래스를 구현해서 사용했는데, 인증요청 객체와 인증된 객체를 조금 더 명확하게 구분하기 위함이다. 물론 하나의 클래스에서 생성자의 차이를 두어 관리할 수 있지만 좀...
여기서 이야기하는 생성자의 차이란 위에서 말했던 Authorities 인자가 포함(총 3개)되면 인증된 객체로 판단하고, 포함되지 않았다면(총 2개) 인증요청 객체로 판단한다는 말이다.
Provider Manager
Provider Manager는 Spring Security에서 제공하는 AuthenticationManager 인터페이스를 구현한 클래스이다. 그냥 이거 쓰면된다. 따로 구현하기엔 개발 코스트가 너무 높다고한다.
이 클래스가 하는 역할은 AuthenticationProvider들을 Collection에 모아놨다가 인증요청 객체가 넘어오면 그에 맞는 Provider를 선택해 인증과정을 거치고 인증된 객체를 반환하도록 도와주는 것이다. Provider에 접근하기 위한 유일한 객체이다.
(실제로 코드를 뜯어보면 반복문을 통해 Provider들을 모아놓은 Collection을 순환하며 일치하는 것을 찾는다)
그러면 바로 이어서 AuthenticationProvider에 대한 설명을 이어가보도록 하자.
AuthenticationProvider
AuthenticationProvider는 실제인증이 일어나는 곳이다. 다르게 생각하면 다른 곳에 인증코드가 들어가지 않도록 주의해야 한다. Provider는 인증요청 객체를 받아 인증과정을 거친 후 예외 혹은 인증후 객체를 반환한다.
예제에서 AuthenticationProvider 클래스를 구성한 예시를 살펴보도록 하자.
우선 중요한 포인트는 3가지이다.
- 해당 클래스는 Component 어노테이션을 추가해야한다!
왜냐하면 SecurityConfig에서 AuthenticationManagerBuilder 객체에 해당 Provider를 추가해줘야 하기 때문이다.
(의존성 주입을 받아 처리해야 하기때문에 컴포넌트로 만들어주는 것이다.) - 실제 인증과정이 구현될 메서드는 authenticate() 이다.
해당 메서드의 인자로 인증요청 객체가 넘어오고, 에러 혹은 인증후 객체를 뿌려줘야 한다. - 해당 Provider를 사용할 Authentication 객체의 종류를 supports() 메서드에서 지정하게 된다.
음.. 좀더 쉽게 말하면 어떤 Authentication 객체가 넘어왔을 때 이 Provider를 사용할지 매핑시켜주는 역할이다.
자세한 인증 과정은 코드를 살펴보도록 하자. 그냥 단순하게 인증요청 객체에 저장되어있는 정보를 꺼내와 디비에 존재하는지 비교하고 없으면 에러, 있으면 인증후 객체를 반환하는 코드이다.
PasswordEncoder는 디비에 패스워드를 저장할 때 암호화해서 저장하기 위해 사용한 인코더이다. 디비에서 꺼내온 정보를 다시 복호화하기 위해 의존성 주입후 사용한 모습이다.
원래는 나만의 에러를 만들어서 처리하는게 좋다. 하지만, 편의상 NoSuchElementException을 그대로 사용했다.
(Account)Context
사실 Context 의 역할을 정확히 뭐라고 설명해야 좋을지 모르겠다. 인증된 객체 정보를 저장하기 위한 공간 + 이를 처리하기 위한 다양한 메서드..? 정도로 생각하면 좋을 듯 하다.
로그인 시스템이여서 AccountContext지 인증하고자 하는 정보가 계정이 아니라 다른 정보라면 다른 형태로 만들어져야 할 것 같아서 괄호를 통해서 표현해봤다ㅎㅎㅎ
위 코드는 예제에서 구현한 AccountContext 클래스이다. private 생성자를 사용해 기본정보를 세팅하도록 구성하고 static 함수에서 해당 생성자를 호출해 반환하도록 구성되어 있다.
(이해가 안된다면 싱글톤 패턴에서 생성자가 활용되는 모습을 연상해보면 좋을듯하다.)
Model을 통해 AccountContext를 생성하는 메서드가 핵심 메서드이다. AccountContext를 생성하고자 할 때 해당 메서드를 호출해 사용하면된다.
AccountContext가 상속받은 User 클래스는 UserDetails 인터페이스를 구현한 클래스고 두 개다 Spring Security에서 제공하는 것들이다. UserDetails에 포함된 메서드에는 어떠한 것들이 있는지 간단하게 살펴보자. Spring Security에서 사용자에 대한 처리할때 사용하라고 만들어둔 클래스이다.
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
내가 만든 예제에서는 Provider에서 인증과정을 거치고 정상적으로 인증이 완료되었다면, 넘어온 데이터를 통해 AccountContext 객체를 생성한다. 그리고 해당객체를 통해 PostAuthorizationToken(인증후 객체)를 생성해 반환해주게 된다.
Authentication Handler
인증 핸들러는 Filter에서 인증 성공/실패 여부에 따라서 호출되는 successfulAuthentication, unsuccessfulAuthentication에서 호출하는 함수를 그냥 내맘대로 표현한 것이다. 각각 핸들러는 AuthenticationSuccessHandler, AuthenticationFailureHandler 인터페이스를 구현해 만들면 된다.
각 핸들러들에 Component 어노테이션을 추가한 이유는, SecurityConfig에서 의존성주입을 받아 Filter 객체를 생성할 때 생성자 인자로 전달한 후 사용하기 위함이다.
(Filter에서 의존성 주입을 받지 않고 SecurityConfig에서 받아서 전달하는 이유는 모르겠군...)
인증결과가 성공면 successHandler를 호출하고, 실패면 failureHandler를 호출하게 된다.
굳이 이 파트를 넣은 이유는 이어서 포스팅할 JWT 토큰인증방식과 이어서 설명하기 위해서인데, successHandler에서 JWT 토큰을 발행해주는 코드까지 맛보기로 보여주기 위함이다.
failureHandler에서는 사용자가 만든 에러를 뿌려주는게 가장 좋은 방법이지만, 예제코드에서는 그냥 log.error를 사용해 간단한 정보만 뿌려주는걸로 정리를 한다. 사용자가 만든 에러를 처리하는 내용은 아래 포스팅을 참고하자.
https://ch4njun.tistory.com/220
여기까지 진행되는 흐름을 한번 더 살펴보며 해당 포스팅을 마무리 지으려고 한다.
(SecurityConfig에 대한 설명이 다 되어있다고 가정한 후 흐름만 정리한다.)
- Filter의 attemptAuthentication에 HTTP Request가 온다.
- 전달받은 HTTP Request에서 인증에 사용될 데이터를 DTO 객체에 담는다.
- DTO 객체를 사용해 인증요청 객체를 생성한다.
- 인증요청 객체를 Provider Manager에 전달한다.
- Provider Manager는 전달받은 인증요청 객체에 맞는 AuthenticationProvider를 선택한다.
- 선택된 Provider에 의해서 인증과정을 진행한 후, 예외 혹은 인증후 객체를 반환한다.
- Provider에 의해서 반환된 예외 혹은 인증후 객체는 Filter의 successfulAuthentication, unsuccessfulAuthentication 메서드에 전달된다.
- 사전에 생성자를 통해 전달받은 successHandler, failureHandler를 호출해 클라이언트에게 HTTP Response를 보낸다.
이 과정에서 몇 가지 설명을 생략한 부분이 있는데 다음과 같다.
- DTO를 선언하는 부분.
- UserRole에 대한 Enum 클래스를 선언하는 부분.
- Account 도메인을 선언하는 부분.
- SecurityConfig를 작성하는 부분.
이는 기본틀을 벗어나지 않고 사용되기 때문에 생략했고 아래의 예제 코드를 통해 그 틀에 맞게 작성하면 될듯하다.
'Back-End > Spring Security' 카테고리의 다른 글
[Spring Boot] JWT 토큰 인증을 위한 두 가지 방법 (feat. API Gateway) (3) | 2021.06.19 |
---|---|
[Spring Security] Login 시스템 개발을 위한 두 가지 방법 (2) | 2021.06.18 |
[Spring Boot] Spring Security(2) - JWT 토큰 인증 방식 구현 (0) | 2021.06.06 |