본문 바로가기
[Spring]/Spring Security

당신의 첫 프로젝트를 위한 스프링 시큐리티(3) - AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails

by 황원용 2024. 8. 4.
728x90
📌 이번 글에서는 AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails 구현에 대해 다룹니다.

🤗 저의 스프링 시큐리티 구현은 아래와 같은 시나리오를 기준으로 합니다.
- 프론트 엔드와 백엔드가 나뉘어 진행되는 프로젝트를 기반으로 하여 스프링 시큐리티 설정에서 로그인 페이지에 대한 설정을 따로 하지 않음
- JWT 토큰 인증 방식을 사용함
- 토큰 관리에 redis를 이용함

 

🙉 이전 글 보기

첫 번째 글부터 정독하시면 보다 쉽게 이해하실 수 있습니다!

https://suzuworld.tistory.com/438 - 당신의 첫 프로젝트를 위한 스프링 시큐리티 톺아보기

이전 글

https://suzuworld.tistory.com/440 - 인증 방식 개념과 AuthenticationFilter

 

📖 목차

스프링 시큐리티 톺아보기

SecurityConfig 구성하기

인증 방식 개념과 AuthenticationFilter 

AuthenticationManager, AuthenticationProvider, UserDetailsService, UserDetails (현재 글)

 

🚪들어가기 전에

  • 지난 글에서는 AuthenticationFilter를 구현하였습니다.
  • 이 필터의 역할은 "/login"이라는 엔드포인트로 들어오는 요청을 필터링합니다.
    • 이때 사용자가 입력한 정보(아이디, 패스워드)를 서버에 저장된 사용자 정보와 비교하여 로그인의 성공/실패 여부를 결정하는 로직을 수행하게 됩니다.
    • 해당 일은 AuthenticationManager라는 녀석이 대신 수행하게 되는데요.

 

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

	private final AuthenticationManager authenticationManager;
    
    ...
    return authenticationManager.authenticate(authenticationToken);
}
  • 그래서 위와 같이 AuthenticationManager를 의존성 주입받아 역할 수행을 대신하도록 하는 것입니다.
  • 이제 이 클래스를 SecurityConfig에 등록하는 작업이 필요합니다.
    • SecurityConfig에 등록해서 "/login" 엔드포인트에 요청이 들어오면 위 필터를 타도록 설정합니다.
    • 또, AuthenticationManager와 같은 인터페이스의 구현체 중  어떤 걸 사용할지 한 곳에서 관리할 수 있습니다.
  • 이번 시간에는 지난 글에서 만든 AuthenticationFilter를 Config에 등록함과 동시에 AutenticationManager와 같은 인터페이스의 구현체를 등록해 보겠습니다.

 

AuthenticationManager

  • AuthenticationManager를 살펴보면 인터페이스임을 확인할 수 있습니다.
  • 인터페이스는 일종의 청사진이죠. 즉, 'AuthenticationManager에는 이런 메서드가 있다는 표준을 제시해 주고, 해당 표준을 준수하여 메서드 내부의 코드 구현해라.'라는 것입니다.

 

  • 실제로 스프링 시큐리티에서 제공하는 구현체는 위와 같은 것들이 있습니다.
  • 그럼 '이 중에 뭘 써야 할까?'라는 물음이 생길 텐데요. 
  • 이 물음에 대한 답변은  Spring Security 공식 문서에 친절히 나와있습니다.

 

Spring Security Docs

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-authenticationmanager

  • ProviderManager is the most commonly used implementation of AuthenticationManager.라고 나와 있네요. 
  • 이를 Config에 추가해봅시다.

 

Spring Bean 등록

  • SecurityConfig에 위와 같이 AuthenticaionManager의 구현체를 Bean으로 등록합니다.

 

ProviderManager 내부

  • 해당 클래스 내부에 들어가면 위와 같이 생성자 메서드에 파라미터로 AuthenticationProvider의 구현체를 받고 있음을 확인할 수 있습니다.

 

📃 아키텍처 일부

  • 아키텍처의 4번에 해당하는 내용입니다.
  • 따라서 저희는 Confing 내의 ProviderManager의 파라미터에 저희가 사용할 AuthenticationProvider를 주입할 것입니다.

 

AuthenticationProvider의 구현체

  • 공식문서에 보면 DaoAuthenticationProvider는 UserDetailService와 PasswordEncoder를 이용하여 username(Id)와 password를 검증한다고 나와있습니다.
  • 딱 저희가 원하는 기능이네요.
  • 따라서, 위 여러 구현체 중에 저희가 사용할 구현체는 DaoAuthenticationProvider입니다.

 

AuthenticationProvider 스프링 빈 등록

  • 위와 같이 DaoAuthenticationProvider를 리턴하는 AuthenticationProvider를 스프링 빈으로 등록하고 ProviderManager의 파라미터에 싣습니다.
  • 이렇게 하면 AuthenticationManager가 DaoAuthenticationProvider에게 로그인 시 입력받은 사용자의 아이디와 패스워드를 검증하라고 시키게 됩니다.

 

필터 추가

  • SecurityFilterChain에 JwtAuthenticationFilter의 인스턴스를 생성하여 url 엔드포인트를 등록하고 파라미터로 authenticationManager()를 넘깁니다.
  • 이제 위의 로그인 필터는 "/login"이라는 엔드포인트로 들어오는 요청을 필터링하여 로그인 검증 로직을 수행할 것입니다.
  • 또한, 수행 시 검증 역할은 스프링 빈으로 등록한 ProviderManager가 DaoAuthenticationProvider에게 시키는 것으로 위의 아키텍처 모습과 동일하게 작동하게 되었습니다.

 

🚨 UserDetailsService와 UserDetails

  • 위 상태에서 그대로 스프링부트를 실행시키면 UserDetailsService가 반드시 세팅되어야 한다는 에러가 발생합니다.
  • UserDetailsService를 AuthenticationProvider에 세팅해야 하는데요. 이 과정은 디테일한 설명이 필요하므로 아키텍처를 다시 살펴봅시다.

 

🤔 아키텍처 

  • 여기서 5번부터 8번까지의 동작 방식을 간단히 설명드리겠습니다.
  • (5) AuthenticationProvider의 구현체는 UserDetailsService를 통해
  • (6) 데이터베이스의 User(Member) 테이블에서 사용자의 정보를 가져와 UserDetails라는 인터페이스의 구현체에 해당 정보를 주입하고
  • (7) 리턴합니다.
  • 그럼 (8) AuthenticationProvider의 구현체는 UserDetails의 구현체 정보에 담긴 사용자의 정보와 AuthenticationManager가 넘긴 입력받은 로그인 정보를 비교하여 로그인 검증 성공/실패 여부를 결정합니다.
  • (9) AuthenticationManager가 필터에 로그인 검증 여부를 넘기면
  • 필터는 결과에 따라 다른 핸들러를 요청하여 사용자에게 HTTP 응답 메시지를 만들고 리턴합니다.
    • 이 부분은 아키텍처에 나와있지 않습니다.

 

SecutiryConfig

  • 자, SecutiryConfig를 다시 보면 현재 스프링 빈으로 등록한 AuthenticationProvider에는 UserDetailsService에 대한 정보를 주입하지 않고 있네요.
  • 정확히 알아보기 위해 구현체인 DaoAuthenticationProvider 내부를 살펴봅시다.

 

DaoAuthenticationProvider

  • 선언된 필드를 보면 UserDetailsService가 있네요.

 

  • 또한, 생성자메서드의 파라미터로 PasswordEncoder가 필요해 보입니다.

 

  • doAfterPropertiesSet이라는 메서드 이름을 보니 properties 세팅이 끝난 후 자동으로 실행되는 메서드인 것 같은데 UserDetailsService를 주입받지 않으면 A UserDetailsService must be set이라는 경고 문구를 던지는 것으로 보아 아까 위에서 본 에러 메시지에 해당하는 메서드임을 알 수 있습니다.

 

🤯 중간 정리

  • 위의 정보만으로 미루어보면 DaoAuthenticationProvider는 최소한 PasswordEncoder와 UserDetailsService를 필요로 함을 알 수 있습니다.
  • UserDetailsService는 데이터베이스에서 해당 사용자의 정보를 가져와 스프링 시큐리티에서 구현한 UserDetails라는 인터페이스의 구현체에 정보를 넣고 비교할 수 있도록 도와주는 객체입니다.
  • 그런데 PasswordEncoder는 도대체 무엇일까요?

 

PasswordEncoder

  • PasswordEncoder의 메서드 명을 보니 패스워드를 인코딩(암호화)하는 메서드인 encode와, 로그인 시 입력받은 패스워드와 저장되어 있는(암호화된) 패스워드를 비교하여 맞는지 틀린 지 확인하는 matches라는 메서드가 있음을 직관적으로 알 수 있네요.
  • 그럼 예상해 보건대, 이 인터페이스의 구현체 종류는 인코딩 방식 등의 차이로 나뉘어 있지 않을까요?

PasswordEncoder의 구현체

  • 패스워드의 인코딩 방식으로 여러 종류의 구현체가 있는 것 같습니다. 여기서 우리는 무엇을 써야 할까요?
  • 이를 알아보기 위해 PasswordEncoder를 왜 인코딩해야 하는지 공식문서에서 찾아봅시다.

 

Spring Security 공식 문서

https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html#page-title

  • 내용을 요약해 보면 이렇습니다.
  • PasswordEncoder는 단방향 암호화로 인코딩합니다.
    • 즉, 암호화만 가능하고 복호화가 불가능합니다.
  • 처음에는 비밀번호를 일반 텍스트 그대로 데이터베이스에 저장했지만, 이는 저장된 데이터에 액세스를 하는 누군가에 비밀번호를 노출시킨다는 문제로 이어졌습니다.
  • 이 문제는 단순히 내부 직원일 수도, SQL Injection과 같은 외부 공격자일수도 있죠.
  • 따라서 단방향 함수를 통해 비밀번호를 암호화하여 저장한 후 로그인 시 입력받은 비밀번호를 해시하여 저장된 해시값과 동일한지 비교하는 방식이 사용되었다고 합니다.
  • 초기에 사용된 SHA-256과 같은 함수는 현대에 들어 하드웨어 기술의 발달로 쉽게 해독할 수 있다는 단점이 발견되어 적응형 단방향 함수(adaptive one-way functions)를 사용한다고 합니다.
  • 이는 의도적으로 리소스를 많이 사용하게 하여 작업 계수를 조정할 수 있는데 의도적으로 암호 확인을 느리게 만드는 것을 말합니다.
  • 예시로는 bcrypt, PBKDF2, scrypt, argon2 등이 있다고 합니다.
  • 저희는 이 중에서 bcrypt를 사용합니다.
    • bcrypt는 적응형 단방향 함수 중 가장 일반적인 예시로 사용되기 때문이고 딱히 이유는 없습니다.

 

PasswordEncoder 적용

  • 이전 시간에 만들었던 회원 가입 컨트롤러로 돌아가봅니다.
  • 왜 여기로 왔을까요?
  • 저희는 데이터베이스에 저장된 비밀번호와 로그인 시 입력받은 비밀번호를 비교해야 합니다.
  • 이때 비밀번호를 일반 텍스트로 저장하지 않고 단방향 함수를 이용하여 인코딩 후 저장해야 합니다.
  • 따라서 회원가입 시 처음부터 비밀번호를 인코딩 후 저장하기 위해 코드를 수정합니다.

 

  • 기존 코드를 지우고 PasswordEncoder를 의존성 주입받아 인코딩 후 저장하도록 변경합니다.
  • 그런데, 선언된 필드에 빨간색 줄이 그어져 있네요.

 

  • 스프링 빈을 등록하지 않아서 생긴 문제입니다.

 

SecurityConfig 수정

PasswordEncoder 스프링 빈 등록

  • SecurityConfig에서 PasswordEncoder를 스프링 빈으로 등록하여 컴파일 에러를 제거합니다.
  • 이쯤에서 다시 상기해 봅시다.
    • 저희는 DaoAuthenticationProvider의 의존성 주입을 위해 PasswordEncoder를 알아보고 구현체 중 하나인 BCryptPasswordEncoder를 스프링 빈으로 등록했습니다.
    • 이제 UserDetailsServiced와 UserDetails 인터페이스를 살펴봅시다.

 

UserDetailsService & UserDetails

  • UserDetailsService의 loadUserByUsername 메서드를 보아 짐작할 수 있는 것은 데이터베이스에 사용자의 username(id)를 넘기면 해당 username으로 사용자 정보를 찾아 UserDetails의 구현체로 리턴하라는 것입니다.
  • 아마 인터페이스에 대한 개념이 확립되신 분들은 전혀 어렵지 않을 텐데 멘붕에 빠지신 분들도 있을 거라고 생각합니다.
  • 특히 순서가 꼬이거나 놓치신 분들이 있을 것 같은데요.
  • 그래서 매우 디테일하게 다시 설명해 보겠습니다. 이해하신 분들은 넘기셔도 됩니다.

 

👮🏻‍♀️ 보충 설명

  • 아키텍처를 보고 다시 정리해 봅시다.
  • (0) 아키텍처에 등장하지 않지만 사용자가 아이디와 비밀번호를 정하고 회원가입을 했습니다. 이때 데이터베이스에는 회원 아이디와 비밀번호를 포함한 사용자 정보가 함께 저장되며 이때 비밀번호는 Bcrypt로 단방향 암호화되어 저장됩니다.
  • (1) Http Request로 사용자의 로그인 시도 요청이 왔습니다. 이 요청 메시지 안에는 사용자가 로그인 시 입력한 아이디와 비밀번호가 담겨있습니다.
  • (2) AuthenticationFilter는 사용자가 입력한 로그인 정보(아이디, 비밀번호)를 UsernamePasswordAuthenticationToken에 담습니다.
  • (3) AuthenticationManager에게 매니저 역할을 부여하고 UsernamePasswordAuthenticationToken을 주면서 로그인 검증을 하라고 시킵니다.
  • (4) AuthenticationManager는 지시자이며, 실제 검증 역할을 수행할 AuthenticationProvider에게 로그인 검증을 지시합니다. 저희는 이 중에 DaoAuthenticationProvider를 사용했습니다.
  • (5) 여기서부터 현재 진행 중인 내용에 해당합니다. AuthenticationProvider는 현재 로그인 시 입력한 정보인 UsernamePasswordAuthenticationToken을 가지고 있습니다. 이제 필요한 건 데이터베이스에 저장되어 있는 사용자의 정보겠죠?
    • 그런데, AuthenticationProvider가 데이터베이스에 접근하는 일을 직접 하지 않습니다. UserDetailsService라는 녀석에게 데이터베이스에 들어가서 정보를 가져오라고 시킵니다.(하청의 하청의 하청입니다.)
  • (6) UserDetailsService는 데이터베이스에 접근하여 정보를 가져오고 해당 정보를 UserDetails의 구현체에 담아 전달합니다.
  • (7) UserDetailsService는 이를 AuthenticationProvider에게 제공하여 실제 비교(로그인 검증)를 시작합니다. 이때 나온 결과가 로그인 성공/실패 여부이며 이를 리턴하는 것이죠.

 

UserDetailsService & UserDetails 구현체 만들기

MemberDetailseService

  • 먼저 UserDetailsService의 구현체인 MemberDetailsService를 만듭니다.
  • 이후 loadUserByUsername 메서드를 오버라이딩하고 코드를 채워 넣으면 됩니다.
  • 이 구현체의 역할은 말 그대로 데이터베이스에 접근하여 사용자의 정보를 가져와 넘기는 것인데요.
  • loadUserByUsername 메서드의 파라미터로 받은 username(id)로 데이터베이스에서 사용자 정보를 조회하는 로직을 추가하면 됩니다.

 

MemberRepository

  • 이를 위해 MemberRepository에 username(id)으로 Member를 조회하는 메서드를 추가합니다.

 

  • @Service 애너테이션으로 스프링 빈에 등록합니다.
  • MemberRepository를 의존성 주입합니다.
  • 깔끔한 코드를 위해 @RequiredArgsConstructor를 사용했습니다.
  • 이제 남은 건 UserDetails의 구현체에 Member 엔티티의 필드 정보를 옮기는 작업인데요.
  • 왜 Member 엔티티를 그대로 넘기지 않는지 의문이 드시는 분들을 위해 설명을 드리자면, 이 부분 역시 스프링 시큐리티가 미리 만들어 놓은 검증 방식(DaoAuthenticationProvider)에 우리가 맞추기 위해서입니다. 스프링 시큐리티 입장에서는 프로젝트마다의 다양한 로그인 정보가 담긴 엔티티의 정보를 모두 커버해야 하는데 이를 위해 UserDetails라는 껍데기를 만들어 두고 여기에 맞춰서 너희의 User 정보가 담긴 엔티티의 필드 값을 옮기라고 하는 것이죠.
  • 저희는 기존 코드를 재활용하기 위해 UserDetails라는 인터페이스를 구현함과 동시에 기존의 Member 엔티티를 상속받는 MemberDetails를 만들겠습니다.

 

MemberDetails

  • MemberDetails 클래스는 MemberDetails class의 이너 클래스로 생성했습니다.
  • 이유는 클래스 간의 논리적 결합 때문인데요.
    • MemberDetails라는 클래스는 지금도 그렇지만 앞으로도 사용하는 곳이 MemberDetailsService에서 리턴할 때뿐입니다.
    • MemberDetailsService 클래스가 AuthenticationProvider에게 데이터베이스에서 꺼내 온 사용자 정보를 검증할 수 있도록 UserDetails 구현체로 만들어 리턴하는 로직 이외에 사용하지 않기 때문에 클래스를 완전히 나누어 만드는 것보다 이너클래스로 만들어 결합하여 관리도 쉽고 직관적으로 사용처가 정해져 있다는 표현하기 위해서라고 생각하면 될 것 같습니다.
  • 또한, Member 엔티티를 상속받았습니다. 이렇게 하여 Member 엔티티의 필드를 사용하게 함과 동시에 Member 엔티티와 연결되어 있음을 드러낼 수 있습니다.
  • @Getter와 같은 Getter 메서드가 있어야 PasswordEncoder의 matches 메서드를 실행할 때 정상적으로 UserDetails의 encoded password(MemberDetails의 password)를 가져올 수 있습니다.
  • 저는 쉬운 이해를 위해 MemberDetails의 필드를 Member 엔티티의 모든 필드로 하였으나, 필요에 따라 추가하거나 제거할 수도 있습니다.
  • 이 부분은 모든 로직을 이해하게 될 때 다시 보시고 커스텀하시면 됩니다.
  • @Override가 붙은 getAuthorities() 메서드는 UserDetails를 구현할 때 반드시 구현해야 하는 메서드로 만약 Member 엔티티에 grade 등의 회원 등급에 관련된 필드가 있다면 활용하여 권한 별로 접근을 제한하는 로직에 사용할 수 있습니다.
  • 이번 예제에서는 그런 디테일한 부분은 제외하였으므로 필요시 추가하여 사용하면 됩니다.
  • log 관련 코드는 눈으로 member 객체가 잘 불러와지는지 확인하기 위해 넣은 코드이므로 안 넣으셔도 무방합니다.

 

📝 잠깐 정리

  • 이제 DaoAuthenticationProvider가 MemberDetailsService에게 사용자가 로그인 시 입력한 username(id)를 건네줄 테니 데이터베이스에서 해당 유저 정보를 찾아 내가 비교할 수 있게 UserDetails의 구현체에 옮기고 리턴하라고 명령하기 위한 준비가 끝났습니다.
  • MemberDetailsService는 파라미터로 받은 username(id)를 가지고 MemberRepository를 이용해 데이터베이스에서 사용자 정보를 찾아 UserDetails의 구현체인 MemberDetails로 리턴할 것입니다.
  • 이를 적용해 봅시다.

 

SecurityConfig

  • 위에서 만든 MemberDetailsService를 의존성 주입받습니다.
  • authenticationProvider() 메서드에 DaoAuthenticationProvider의 인스턴스를 생성하고, passeordEncoder와 memberDetailsService를 주입해 줍니다.
  • AuthenticationManager -> AuthenticationProvider -> UserDetailsService -> UserDetails 리턴까지 이어지는 로직이 완성되었습니다.

 

👀 DaoAuthenticationProvider 살펴보기

retrieveUser 메서드

  • DaoAuthenticationProvider 클래스의 retrieveUser 메서드를 살펴보면 UserDetailsService에서 loadUserByUsername 메서드를 호출하여 리턴 받은 UserDetails 객체를  loadedUser에 싣는 것을 확인할 수 있습니다.

 

addutionalAuthenticationChecks 메서드

  • AuthenticationManager로부터 받은 UsernamePasswordAuthenticationToken과 UserDetailsService에게 받은 UserDetails에서 패스워드를 꺼내어 passwordEncoder의 matches 메서드로 두 패스워드의 인코딩 값이 같은지 확인하는 메서드도 보입니다.

 

🏃🏻 다음으로

  • 이제 로그인 검증에 대한 구현이 거의 마무리되었습니다.
  • 로그인이 검증이 성공적으로 끝나면 무엇을 해야 할까요?
  • 일단, 응답값을 클라이언트에게 보내야겠죠. 반대로 실패했을 시에도 실패에 따른 응답값을 보내야 할 것입니다.
  • 또한, JWT 토큰을 발급해 줘야겠죠? 이때 Refresh Token의 경우에는 서버에 저장하게 되는데 저희는 Redis를 활용할 것입니다.
  • 아직 갈 길이 멉니다. 다음 글에서는 이 모든 걸 구현하는 시간을 가져보겠습니다.
728x90