Spring Security 란 무엇일까??
항상 새로 접하게되는 것들이 있다면 나 혼자만 이해를 못하는 것이 아닐까 생각이 든댜..
저는 벌레입니다 죄송합니다..
Spring Security에 대한 특강을 듣다가 특강만 보고
바로 실제로 적용시키는데에는 아직 무리라고 판단!!!!!!
따라서 현재 내 글은 스프링 시큐리티 (편의상 한글로 쓰겠어) 를 이해하는 것이 주 목적이며
거기에서 나아가 실제로 적용시킬 때 어떤 흐름으로 진행될지를 정리하고자한다.
우선 스프링 시큐리티의 흐름을 정확히 이해하고 가는게 우선이지 않을까?
Spring Security는 결국 애플리케이션에서 사용자가 누구인지 확인(인증)하고,
그 사용자가 무엇을 할 수 있는지 결정(인가) 하는 역할을 담당하는 것이라고 보면 된다.
인증은 사용자가 본인이 누구인지 증명하는 과정이다.
예를 들어, 사용자가 이메일과 비밀번호로 로그인할 때 진행된다.
사용자 요청:
AuthenticationFilter (인증 필터) :
AuthenticationManager (인증 관리자) :
AuthenticationProvider (인증 공급자) :
SecurityContext (보안 컨텍스트) :
인가는 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정이다.
예를 들어, 관리 페이지(/admin
)는 관리자만 접근할 수 있도록 설정한다.
/admin
등) 에 접근 요청을 보낸다.AuthorizationFilter
가 이를 가로챈다.구성 요소 | 역할 |
---|---|
AuthenticationFilter | 인증 요청을 가로채고 인증 매니저에 전달. |
AuthenticationManager | 인증 요청을 인증 공급자에게 위임. |
AuthenticationProvider | 실제 인증 로직 구현. |
UserDetailsService | 사용자 정보를 데이터베이스에서 조회. |
PasswordEncoder | 비밀번호 암호화 및 검증. |
SecurityContext | 인증된 사용자의 정보를 저장. |
AuthorizationFilter | 사용자의 권한을 확인하고 요청 허용 여부 결정. |
Spring Security는 인증(Authentication)과 인가(Authorization)를 자동화하고
간소화하는 강력한 보안 프레임워크이다.
위에서 설명한 구성 요소들이 유기적으로 동작하여
사용자의 신원을 확인하고, 적절한 권한을 가진 사용자만 리소스에 접근하도록 보장해준다.
좋았어 이제 대충 흐름은 알았어. 그러면 이제는 흐름에서 각 역할을 상세하게 파보자.
파트의 대제목도 스프링 시큐리티의 큰 그림이니까 진짜로 큰 그림으로 살펴보자 ㅋㅋㅋ
그림을 살펴보면 색깔이 노랗게 칠해진 부분, 초록색으로 칠해진 부분이 있다.
노란색은 클래스, 초록색은 인터페이스를 의미한다!!
스프링 시큐리티의 흐름인데 대부분 인터페이스로 되어있다? 이것은 무슨 의미일까?
스프링 시큐리티는 보안을 도와주는 프레임워크
이다.
즉 시큐리티의 기본 기능들은 내부적으로 인터페이스를 구현한 구현체 클래스들에 이미
구현되어 있다는 사실을 의미한다!!
이미 구현되어있으니까 그냥 갖다 쓰면 되는거 아니야??
맞는 말이다. 하지만 모든 경우의 보안을 보장하는 것이 아니기에 우리가 구현할 프로젝트에
필요한 보안 수준으로 필요한 구성요소들만 재정의하여 구현해서 우리 프로젝트만의
맞춤 보안을 만들어내는 것이 우리의 역할이다.
스프링 시큐리티를 프로젝트에 적용할 때 다른 인터페이스들은 기본 구현체를 사용하는 경우가
많지만, UserDetailsService, UserDetails는 대부분의 프로젝트에서 사용자 인증을 위해
커스터 마이징이 필요한 인터페이스이다.
왜 구현해야하는가?
UserDetailsService는 스프링 시큐리티가 사용자를 인증하기 위해
사용자 정보를 조회하는 인터페이스이다.
기본적으로 사용자의 데이터(예: 이메일, 비밀번호, 권한)를 커스텀 데이터베이스나
API에서 조회해야 하기 때문에, 이를 구현하는 커스텀 클래스가 필요한 것이다.
Spring Security 의 인증 과정을 위해 사용자 정보를 구성하고 인증 시스템과 연동하기 위함이다.
쉽게 설명하자면 Spring Security는 사용자 인증 과정에서
UserDetailsService
와 UserDetails
를 모두 사용하는데 우리는 각 프로젝트마다 DB도 다르고 사용자의 데이터도 다를 것이기에 구현해야한다는 의미이다.
UserDetails
UserDetails
객체를 통해 사용자 정보를 관리.getUsername
)getPassword
)getAuthorities
)isAccountNonExpired
, isAccountNonLocked
, isCredentialsNonExpired
, isEnabled
)UserDetailsService
UserDetails
객체로 반환해야 한다.사용자가 로그인 요청:
Spring Security의 인증 절차:
AuthenticationManager
→ AuthenticationProvider
→ UserDetailsService
호출.UserDetailsService
는 입력받은 username(예: 이메일)을 이용해 사용자 정보를 조회.조회된 사용자 정보 반환:
UserDetailsService
는 조회한 사용자 정보를 UserDetails
로 반환.UserDetails
객체를 사용해 비밀번호 검증 및 인증 처리.UserDetailsServiceImpl
와 UserDetailsImpl
의 역할UserDetailsServiceImpl
:
UserDetailsImpl
객체로 변환하여 반환한다.UserDetailsImpl
:
사용자 정보를 담는 객체로,
Spring Security가 사용자 인증 및 권한 관리를 수행하는 데 사용된다.
getAuthorities()
를 통해 사용자 권한을 제공하고, 계정 상태를 관리하는 메서드(isAccountNonExpired
등)를 구현
실제 구현체의 모습은 이 다음 파트인 특강을 기준으로 코드의 역할 분석하기! 에서
살펴볼 예정이니 조급해하지말고 이런 것이구나~ 를 알고 진행하면 된다!!!
NoOpPasswordEncoder
: 인코딩하지 않는다. ㅋㅋㅋ 네?StandardPasswordEncoder
: SHA-256을 이용해 암호를 해시한다.Pbkdf2PasswordEncoder
: PBKDF2를 이용한다.BCryptPasswordEncoder
: bcrypt 해싱 함수로 암호를 인코딩한다.SCryptPasswordEncoder
: scrypt 해싱 함수로 암호를 인코딩한다.패스워드 인코더는 구현체를 만들지 않고 이미 만들어져있는 것들을 사용한다.
대부분은 BCrypt, SCrypt 를 사용하고 함께 사용할 수 있다.
그냥 있는거 사용하는구나 하면 편리하다.
그래 일단 흐름은 대충 알게된 것 같아. 근데 예시 코드를 봐서는 뭐가 뭔지 구분이 안되던데???
이 세계를 파괴하고 싶지만 그럴 수는 없으니
특강에서 주어진 각 예시 코드의 모습, 역할과 서로 어떻게 연관되어 동작하는지 분석해보자
@PostMapping("/login")
public ResponseEntity<CommonResponseBody<JwtAuthResponse>> login(
@Valid @RequestBody AccountRequest accountRequest) {
JwtAuthResponse authResponse = this.accountService.login(accountRequest);
return ResponseEntity.ok(new CommonResponseBody<>("로그인 성공", authResponse));
}
AccountController
의 /login
API에 요청을 보낸다.AccountController
는 입력받은 로그인 정보를 AccountService
의 login
메서드에 전달한다.public JwtAuthResponse login(AccountRequest accountRequest) { // 사용자 확인.
// 사용자 확인.
Member member = this.memberRepository.findByEmail(accountRequest.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("이메일에 해당하는 사용자를 찾을 수 없습니다."));
this.validatePassword(accountRequest.getPassword(), member.getPassword());
// 사용자 인증 후 인증 객체를 저장
Authentication authentication = this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
accountRequest.getEmail(),
accountRequest.getPassword())
);
log.info("SecurityContext에 Authentication 저장.");
SecurityContextHolder.getContext().setAuthentication(authentication);
// 토큰 생성
String accessToken = this.jwtProvider.generateToken(authentication);
log.info("토큰 생성: {}", accessToken);
return new JwtAuthResponse(AuthenticationScheme.BEARER.getName(), accessToken);
}
Member member = this.memberRepository.findByEmail(accountRequest.getEmail())
.orElseThrow(() -> new UsernameNotFoundException("이메일에 해당하는 사용자를 찾을 수 없습니다."));
this.validatePassword(accountRequest.getPassword(), member.getPassword());
// 사용자 인증 후 인증 객체를 저장
Authentication authentication = this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
accountRequest.getEmail(),
accountRequest.getPassword())
);
- Spring Security의 `AuthenticationManager`를 사용해 인증을 진행한다.
- 내부적으로 `AuthenticationProvider`로 인증 요청이 전달된다.
하이잇~
내부적으로 AuthenticationManager -> AuthenticationProvider 로 위임하는 과정은
어떻게 이루어지는 걸까? 답은 SecurityConfig 클래스에 존재한다
같이 비밀을 파헤쳐 보도록 하자!!
@Configuration
@RequiredArgsConstructor
@Slf4j(topic = "Security::SecurityConfig")
public class SecurityConfig {
![](https://velog.velcdn.com/images/weskii/post/a116a486-b657-4736-a8c2-1530ab4c7b05/image.png)
private final UserDetailsService userDetailsService;
@Bean
BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
log.info("AuthenticationManager에 위임.");
return config.getAuthenticationManager();
}
@Bean
AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
log.info("AuthenticationProvider 설정. 구현체: {}", authProvider.getClass().getSimpleName());
log.info("UserDetailsService에 사용자 관리 위임. 구현체: {}",
this.userDetailsService.getClass().getSimpleName());
authProvider.setUserDetailsService(this.userDetailsService);
log.info("PasswordEncoder에 암호 검증 위임. 구현체: {}",
this.passwordEncoder().getClass().getSimpleName());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
}
SecuriyConfig 클래스는 Spring Security에 필요한
AuthenticationManager, AuthenticationProvider, PasswordEncoder를
빈으로 등록하는 중요한 Config 클래스이다.
이 Config 클래스 파일을 보고 알 수 있는 사실은 다음과 같다.
BcryptPasswordEncoder 를 bean 으로 등록.
AuthenticationManager 를 bean 으로 등록
AuthenticationProvider 를 bean 으로 등록
AuthenticationManager 와 AuthenticationProvider 를 Bean 으로 등록함으로써
Spring Security가 의존성 주입(DI)으로 관리할 수 있게 되고
따라서 내부적으로 AuthenticationManager
가 우리가 등록한
DaoAuthenticationProvider, BCryptPasswordEncoder, UserDetailsServiceImpl
를
가진 AuthenticationProvider
에게 자동적으로 인증 요청을 위임하게 되는 것이고
우리는 자연스레 AuthenticationManager
의 autheticate 를 사용하기만 하면 되는 것이다.
또한 AccountService 에서 사용하던 PasswordEncoder 도 여기서 SecurityConfig 에서
bean 으로 등록한 BCrypt 가 사용되기에 아까 BCrypt 방식으로 동작한다고 한 것이다.
SecurityConfig 가 왜 있는지 의아했는데 드디어 파악을 성공했다. 휴~!
인줄 알았지? Spring Security 는 나를 그렇게 쉽게 놓아주지 않아..
Authentication authentication = this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
accountRequest.getEmail(),
accountRequest.getPassword())
);
여기서 생성되는 UsernamePasswordAuthenticationToken
은 대체 뭘까?
UsernamePasswordAuthenticationToken
은
Spring Security에서 사용자의 인증 정보(Authentication)를 나타내는 클래스 중 하나다.
사용자의 이메일(또는 username)과 비밀번호(password)를 포함하여 인증 요청을 전달하거나,
인증이 완료된 후 사용자의 인증 상태를 표현하는 데 사용된다.
즉 Authentication 을 나타내는 것 중에서 하나라는 소리 같은데....
이왕 알아볼 거 좀 더 파보도록 하자.
Authentication
인터페이스를 구현한 클래스.AuthenticationManager
에 전달AuthenticationProvider
에서 사용됨.public UsernamePasswordAuthenticationToken(Object principal, Object credentials)
principal
: 사용자의 고유 식별자(예: 이메일, username).credentials
: 사용자의 비밀번호(평문으로 전달).public UsernamePasswordAuthenticationToken(
Object principal,
Object credentials,
Collection<? extends GrantedAuthority> authorities)
principal
: 인증된 사용자 정보(UserDetails
또는 username
)credentials
: 인증 후에는 보통 null로 설정(비밀번호를 더 이상 보관하지 않음)authorities
: 사용자의 권한 정보(ROLE_USER, ROLE_ADMIN 등)인증 로직에서 사용자의 입력값(이메일과 비밀번호)을 담아 AuthenticationManager
로 전달한다.
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(email, password)
);
email
: 사용자가 입력한 이메일.password
: 사용자가 입력한 비밀번호.AuthenticationManager
에 전달되어 인증 요청을 나타낸다.Spring Security는 인증이 성공하면
UsernamePasswordAuthenticationToken
을 인증된 상태로 변환하고,
사용자 정보와 권한 정보를 담아 SecurityContext
에 저장한다.
UsernamePasswordAuthenticationToken authenticatedToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticatedToken);
즉 이러한 과정이 어디인가 추가되어있겠쥬?
자!!! 이제 우리는 AuthenticationManager 에게 넘겨주는 매개변수에 대해서 알게 되었고
그 다음에 일어나는 과정들을 알아야겠지?
맨 처음 스프링 시큐리티의 흐름을 공부할 때의 순서를 기억해보자.
인증 과정이 일어나면
요청 -> AuthenticationFilter -> AuthenticationManager -> AuthenticationProvider
이러한 순서로 진행되었는데 지금 우리는 Manager 의 authenticate() 를 호출했기 때문에
당연히 Provider로 넘어가게 될 것이고 그러면 UserDetailsService, UserDetails 를
사용해야할 것이니 이것들이 구현되어 있겠죠??
당연히 특강 코드 예시에도 두 녀석들이 들어간다. 어떻게 구현되었는지 드디어 살펴보도록 하자!!
@Service
@RequiredArgsConstructor
@Slf4j(topic = "Security::UserDetailsServiceImpl")
public class UserDetailsServiceImpl implements UserDetailsService {
/**
* Member entity의 repository.
*/
private final MemberRepository memberRepository;
/**
* 입력받은 이메일에 해당하는 사용자 정보를 찾아 리턴.
*
* @param username username
* @return 해당하는 사용자의 {@link UserDetailsImpl} 객체
* @throws UsernameNotFoundException 이메일에 해당하는 사용자를 찾지 못한 경우
* @apiNote 이 애플리케이션에서는 사용자의 이메일을 {@code username}으로 사용합니다
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = this.memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
log.info("찾은 사용자: {}", username);
return new UserDetailsImpl(member);
}
}
로그인 요청이 들어왔으므로 AuthenticationProvider
가
UserDetailsService 의 loadUserByUsername()
를 사용하여
해당 멤버가 DB 에 존재하는지 확인하고 해당 멤버가 존재한다면
해당 멤버의 객체로 UserDetails
를 생성하여 리턴해준다.
이 UserDetails
정보가 Authentication
객체에 포함되는 정보이다.
loadUserByUsername()
메서드를 구현해준 것이 UserDetailsServiceImpl
인 것이다.
그렇다면 UserDetails
는 무엇일까??
@Getter
@RequiredArgsConstructor
@Slf4j(topic = "Security::UserDetailsImpl")
public class UserDetailsImpl implements UserDetails {
/**
* Member entity.
*/
private final Member member;
/**
* 계정의 권한 리스트를 리턴.
*
* @return {@code Collection<? extends GrantedAuthority>}
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Role role = this.member.getRole();
log.info("사용자 권한: {}", role.getAuthorities());
return new ArrayList<>(role.getAuthorities());
}
/**
* 사용자의 자격 증명 반환.
*
* @return 암호
*/
@Override
public String getPassword() {
return this.member.getPassword();
}
/**
* 사용자의 자격 증명 반환.
*
* @return 사용자 이름
*/
@Override
public String getUsername() {
return this.member.getEmail();
}
/**
* 계정 만료.
*
* @return 사용 여부
* @apiNote 사용하지 않을 경우 true를 리턴하도록 재정의.
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 계정 잠금.
*
* @return 사용 여부
* @apiNote 사용하지 않을 경우 true를 리턴하도록 재정의.
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 자격 증명 만료.
*
* @return 사용 여부
* @apiNote 사용하지 않을 경우 true를 리턴하도록 재정의.
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 계정 활성화.
*
* @return 사용 여부
* @apiNote 사용할 경우 true를 리턴하도록 재정의.
*/
@Override
public boolean isEnabled() {
return true;
}
}
Spring Security의 UserDetails
인터페이스를 구현하여 사용자 정보를 정의할 수 있다.
이 클래스는 사용자의 권한(Role)과 기본 정보를 제공하며
Spring Security가 인증 처리 시 사용자 정보를 다룰 수 있도록 해준다.
loadUserByUsername() 메서드에서 매개변수로 넘겨주던 Member 엔티티가 존재하는 모습을
볼 수 있으며 해당 UserDetailsImpl 클래스를 통해 Spring Security 는 인증/인가에 필요한
다양한 작업들을 할 수 있다.
Spring Security가 Member 엔티티를 바로 사용하지 않고 UserDetails 를 사용하는 이유는
사용자 정보를 표준화된 방식으로 처리하기 위함이다.
사용자와 관련된 모든 정보를 포함하는 엔티티를 Spring Security 에서 사용할 수 있게
변환해주는 과정이라고 생각하면 쉽다.
AuthenticationManager.authenticate 메서드 하나를 파보는데 이렇게 많은 것들이
필요하다니... 방대한 흐름을 이해하는게 참 쉽지가 않다.
그래도!!! 많은 궁금증들을 해결했으니 이제 남은 로그인 로직들을 살펴보자!!
log.info("SecurityContext에 Authentication 저장.");
SecurityContextHolder.getContext().setAuthentication(authentication);
Authentication
객체가 생성되고,SecurityContextHolder
를 통해SecurityContext
에 저장한다.// 토큰 생성
String accessToken = this.jwtProvider.generateToken(authentication);
log.info("토큰 생성: {}", accessToken);
return new JwtAuthResponse(AuthenticationScheme.BEARER.getName(), accessToken);
Authentication
)를 기반으로 JwtProvider
에서 JWT 토큰을 생성.JwtAuthResponse
객체에 JWT와 인증 스키마(Bearer
)를 포함하여 반환.로그인 과정 마지막에 반환해주는 JwtAuthResponse 와 AuthenticationScheme.BEARER
이 무엇인지 다시 궁금해졌다.
이번에는 이 녀석들에 대해서 알아보자
@Getter
@NoArgsConstructor(access = AccessLevel.PACKAGE)
public class JwtAuthResponse {
/**
* access token 인증 방식.
*/
private String tokenAuthScheme;
/**
* access token.
*/
private String accessToken;
/**
* 생성자.
*/
public JwtAuthResponse(String tokenAuthScheme, String accessToken) {
this.tokenAuthScheme = tokenAuthScheme;
this.accessToken = accessToken;
}
}
JwtAuthResponse는 사용자에게 인증 토큰(JWT) 과 인증 방식을 제공하기 위해 사용되는 데이터 전송 객체(DTO) 이다.
아 이 친구는 별 거 아닌 친구네~
그냥 생성한 토큰을 반환해주는 DTO 객체구나??
근데 저건 뭐지?? Sche..me..?? 저게 인증 방식인건가..??
내가 아는 Bear 는 이런 것 밖에 없는데 뭐지??
@Getter
@RequiredArgsConstructor
public enum AuthenticationScheme {
BEARER("Bearer");
private final String name;
/**
* Authorization 헤더의 값으로 사용될 prefix를 생성.
*
* @param authenticationScheme {@link AuthenticationScheme}
* @return 생성된 prefix
*/
public static String generateType(AuthenticationScheme authenticationScheme) {
return authenticationScheme.getName() + " ";
}
}
AuthenticationScheme는 서버가 클라이언트와 통신할 때
사용하는 인증 방식을 명시적으로 정의하는 열거형(Enum) 이라고 한다.
왜 이러한 열거형 BEARER 이 필요할까?
HTTP 헤더 표준:
Authorization
헤더는 다양한 인증 방식을 지원한다Authorization: Bearer <JWT>
Bearer
: 인증 방식.<JWT>
: 실제 인증에 사용되는 토큰.반환해줄 때 토큰 기반 인증이라는 것을 명시해주는 Bearer 을 토큰 앞에 넣음으로써
다음에 사용자가 해당 토큰 값을 사용하면 JWT 를 쓰는 것임을 서버가 인식할 수 있게
즉 Spring Security 가 Bearer 방식을 기반으로 토큰 인증을 처리할 수 있게
도와주는 역할을 하는 것이다.
클라이언트가 /login 요청을 통해 JWT를 수신.
서버는 JwtAuthResponse를 통해 인증 방식(Bearer)과 토큰을 반환.
{
"tokenAuthScheme": "Bearer",
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
이렇게 요청이 와야 Spring Security 가 이해하고 인증/인가 요청을 할 수 있는 것이다~
즉 클라이언트-서버 간 JWT 기반 인증을 원활히 수행하기 위한 핵심 요소이다
/login
요청 → AccountController
→ AccountService
.AuthenticationManager
가 AuthenticationProvider
로 인증 요청 위임.Authentication
객체를 SecurityContext
에 저장.JwtProvider
가 JWT 생성.JwtAuthResponse
로 토큰 반환.드디어 로그인 과정이 끝났다!!!!! 로그인이 성공하면 인증이
하지만 이제 시작인걸 ^^
본격적으로 요청에 대한 인증/인가 과정을 알아보기 전에 로그아웃은 어떻게 처리하는지 살펴보자!
@PostMapping("/logout")
public ResponseEntity<CommonResponseBody<String>> logout(HttpServletRequest request,
HttpServletResponse response, Authentication authentication)
throws UsernameNotFoundException {
// 인증 정보가 있다면 로그아웃 처리.
if (authentication != null && authentication.isAuthenticated()) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
log.info("인증 객체의 삭제 확인: {}", SecurityContextHolder.getContext().getAuthentication() == null);
return ResponseEntity.ok(new CommonResponseBody<>("로그아웃 성공."));
}
// 인증 정보가 없다면 인증되지 않았기 때문에 로그인 필요.
throw new UsernameNotFoundException("로그인이 먼저 필요합니다.");
}
이 로그아웃 메서드는 Spring Security를 사용하여
인증된 사용자의 세션과 관련된 모든 인증 정보를 삭제하여 로그아웃을 처리하는 메서드이다.
if (authentication != null && authentication.isAuthenticated()) {
// ...
} else {
throw new UsernameNotFoundException("로그인이 먼저 필요합니다.");
}
authentication
:authentication.isAuthenticated()
:new SecurityContextLogoutHandler().logout(request, response, authentication);
SecurityContextLogoutHandler
:SecurityContext
를 초기화하여 인증 정보를 삭제.HttpServletRequest
: 클라이언트의 요청.HttpServletResponse
: 클라이언트로 반환될 응답.Authentication
: 현재 인증된 사용자 정보.log.info("인증 객체의 삭제 확인: {}", SecurityContextHolder.getContext().getAuthentication() == null);
SecurityContextHolder
:SecurityContext
가 초기화되었는지 확인.return ResponseEntity.ok(new CommonResponseBody<>("로그아웃 성공."));
로그아웃은 그냥 있는 것들을 잘 사용해서 SecurityContext 를 초기화하는 과정이라고
이해하면 될 것 같다!!
지금까지는 인증 성공 후 토큰을 반환하는 것을 알아본 것이고
이제 사용자가 보호된 리소스에 요청을 수행할 때 어떻게 걸러지는지 알아봐야 할 차례이다.
거른다는 말은 좀 그러니까 있어보이게 인증, 인가 검증 이라고 할까 ?? ^-^
거의 다 왔으니 힘내보자!!!!!
JwtAuthFilter 클라이언트 요청에 포함된 JWT 토큰을 검증하고,
요청이 인증된 사용자로부터 온 것인지 확인하는 필터이다.
전체적인 코드 모습을 보고 세세하게 알아보도록 하자!
@Component
@RequiredArgsConstructor
@Slf4j(topic = "Security::JwtAuthFilter")
public class JwtAuthFilter extends OncePerRequestFilter {
/**
* JWT 토큰 제공자.
*/
private final JwtProvider jwtProvider;
/**
* UserDetailsService.
*/
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("URI: {}", request.getRequestURI());
this.authenticate(request);
filterChain.doFilter(request, response);
}
private void authenticate(HttpServletRequest request) {
log.info("인증 처리.");
// 토큰 검증.
String token = this.getTokenFromRequest(request);
if (!jwtProvider.validToken(token)) {
return;
}
// 토큰으로부텨 username을 추출.
String username = this.jwtProvider.getUsername(token);
// username에 해당되는 사용자를 찾는다.
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// SecurityContext에 인증 객체 저장.
this.setAuthentication(request, userDetails);
}
private String getTokenFromRequest(HttpServletRequest request) {
final String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
final String headerPrefix = AuthenticationScheme.generateType(AuthenticationScheme.BEARER);
boolean tokenFound =
StringUtils.hasText(bearerToken) && bearerToken.startsWith(headerPrefix);
if (tokenFound) {
return bearerToken.substring(headerPrefix.length());
}
return null;
}
private void setAuthentication(HttpServletRequest request, UserDetails userDetails) {
log.info("SecurityContext에 Authentication 저장.");
// 찾아온 사용자 정보로 인증 객체를 생성.
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, userDetails.getPassword(), userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContext에 인증 객체 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
public class JwtAuthFilter extends OncePerRequestFilter
JwtAuthFilter 는 OncePerRequestFilter 라는 필터를 상속받는다.
나는 궁금한건 못참으니 이것 먼저 알아보도록하자!!
OncePerRequestFilter
는 Spring Framework에서 제공하는 추상 클래스이다.
이 클래스는 필터(Filter)로 동작하며,
이름 그대로 하나의 요청(Request)에 대해 한 번만 실행되도록 설계된 필터를 구현할 때 사용된다.
이 클래스는 javax.servlet.Filter
인터페이스를 간단히 구현하며,
doFilterInternal
메서드를 오버라이드하여 동작을 정의한다.
public abstract class OncePerRequestFilter implements Filter {
@Override
public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 이미 처리된 요청인지 확인 (중복 실행 방지)
if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
filterChain.doFilter(request, response);
}
//생략.....
// 한 번만 실행
doFilterInternal(httpRequest, (HttpServletResponse) response, filterChain);
}
protected boolean shouldNotFilter(HttpServletRequest request) {
return false; // 기본적으로 모든 요청에 대해 필터를 실행
}
protected abstract void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException;
}
doFilter
:
doFilterInternal
을 호출한다.doFilterInternal
:
shouldNotFilter
:
false
(모든 요청에 대해 필터 실행).중복 실행 방지:
필터 로직 캡슐화:
doFilterInternal
메서드에 핵심 로직만 작성.doFilter
와 같은 복잡한 처리는 내부적으로 관리.OncePerRequestFilter는 Spring에서 제공하는 필터로,
한 요청당 한 번만 실행되는 필터를 구현할 때 사용된다.
주로 Spring Security에서 JWT 인증, 세션 검증 등의 작업에 사용된다.
개발자는 doFilterInternal 메서드를 오버라이드하여 필터 로직을 구현한다.
궁금증이 해결되었으니 다음으로 넘어가자!
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("URI: {}", request.getRequestURI());
this.authenticate(request);
filterChain.doFilter(request, response);
}
private void authenticate(HttpServletRequest request) {
...
}
private String getTokenFromRequest(HttpServletRequest request) {
...
}
private void setAuthentication(HttpServletRequest request, UserDetails userDetails) {
...
}
doFilterInternal 내부에서 authenticate 를 호출한다.
이 때 매개변수는 HttpServletRequest 를 넣어준다.
private void authenticate(HttpServletRequest request) {
// 토큰 검증.
String token = this.getTokenFromRequest(request);
if (!jwtProvider.validToken(token)) {
return;
}
//....
}
authenticate 메서드는 request 를 사용하여 우선 토큰 값을 추출하는
getTokenFromRequest 메서드를 호출한다.
private String getTokenFromRequest(HttpServletRequest request) {
final String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
final String headerPrefix = AuthenticationScheme.generateType(AuthenticationScheme.BEARER);
boolean tokenFound =
StringUtils.hasText(bearerToken) && bearerToken.startsWith(headerPrefix);
if (tokenFound) {
return bearerToken.substring(headerPrefix.length());
}
return null;
}
getTokenFromRequest 메서드의 작업을 살펴보자.
이제 이렇게 반환받은 토큰값으로 어떤 작업을 마저 진행하는지 살펴보자.
private void authenticate(HttpServletRequest request) {
log.info("인증 처리.");
// 토큰 검증.
String token = this.getTokenFromRequest(request);
if (!jwtProvider.validToken(token)) {
return;
}
// 토큰으로부텨 username을 추출.
String username = this.jwtProvider.getUsername(token);
// username에 해당되는 사용자를 찾는다.
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// SecurityContext에 인증 객체 저장.
this.setAuthentication(request, userDetails);
}
반환받은 String token 값을 jwtProvider를 통해 검증
토큰으로부터 username 추출,
UserDetailsService 의 loadUserByUsername 메서드를 통해 해당 사용자의
UserDetails 를 생성하고 반환받는다.
SecurityContext 에 해당 객체를 저장하는 setAuthentication 메서드를 호출한다.
private void setAuthentication(HttpServletRequest request, UserDetails userDetails) {
log.info("SecurityContext에 Authentication 저장.");
// 찾아온 사용자 정보로 인증 객체를 생성.
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, userDetails.getPassword(), userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// SecurityContext에 인증 객체 저장.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
이 메서드가 하는 작업이 어디서 본 것처럼 익숙하지 않은가???
UsernamePasswordAuthenticationToken 에 대해서 알아볼 때
Spring Security 가 인증이 성공하면 해당 객체를 인증된 상태로 변환하고
사용자 정보, 권한 정보를 담아서 SecurityContet 에 저장한다고 했었다!
이걸 JwtAuthFilter 에서 구현하여 완전한 Authentication 객체로 변환해주는 것이었다.
필터에서 인증이 성공하면 완전한 Authentication 객체로 SecurityContext 에 저장되는구나
결국 위에서 배웠던 것들이 하나로 이어지고있는 느낌이야~
WebConfig 에서 이제 이 필터를 등록하면 사용되는 것이야~~ 출격이요 뿌뿌~~
그전에 잠깐!!!!
Spring Security에서 인증(Authentication) 및 인가(Authorization) 과정에서
발생하는 예외 상황을 처리하기 위한 구성 요소들을 알아보자.
@Component
public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* Spring Security 예외를 처리하기 위한 resolver.
*/
private final HandlerExceptionResolver resolver;
/**
* 생성자.
*/
public DelegatedAuthenticationEntryPoint(
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
/**
* {@inheritDoc}
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
resolver.resolveException(request, response, null, authException);
}
}
Spring Security의 AuthenticationEntryPoint 인터페이스를 구현하여
인증 실패 시 예외를 처리하는 구현체이다.
이는 인증되지 않은 사용자가 보호된 리소스에 접근하려고 할 때 실행되는 엔트리 포인트이다.
commence 메서드 호출:
인증되지 않은 사용자가 접근하려고 하면 Spring Security가 이 메서드를 호출한다.
예외(AuthenticationException)를 처리하여 적절한 응답을 반환.
HandlerExceptionResolver를 사용한 예외 처리:
내부적으로 Spring MVC의 HandlerExceptionResolver를 호출하여 예외를 처리.
클라이언트에게 401 Unauthorized 상태 코드를 반환하거나 커스텀 메시지를 제공한다.
@Component
public class DelegatedAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* Spring Security 예외를 처리하기 위한 resolver.
*/
private final HandlerExceptionResolver resolver;
/**
* 생성자.
*/
public DelegatedAuthenticationEntryPoint(
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
/**
* {@inheritDoc}
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
resolver.resolveException(request, response, null, authException);
}
}
Spring Security의 AccessDeniedHandler 인터페이스를 구현하여 인가 실패 시 예외를 처리
사용자가 권한이 없어서 리소스에 접근하지 못할 때 실행되는 핸들러이다.
handle 메서드 호출:
Spring Security가 사용자의 권한(Role)을 확인한 후, 권한 부족 시 이 메서드를 호출한다
예외(AccessDeniedException)를 처리하여 적절한 응답을 반환.
HandlerExceptionResolver를 사용한 예외 처리:
내부적으로 Spring MVC의 HandlerExceptionResolver를 호출하여 예외를 처리.
클라이언트에게 403 Forbidden 상태 코드를 반환하거나 커스텀 메시지를 제공한다.
핸들러(Handler)와 엔트리포인트(EntryPoint) 의 역할이 비슷해 보이는 데다가
HandlerExceptionResolver 을 사용하여 예외를 처리하는 것도 똑같은데
왜 명명을 굳이 다르게 한 것인지 의문이 들었다.
언제 동작하는가?
인증(Authentication)이 필요한 상황인데,
사용자가 인증되지 않은 상태에서 보호된 리소스에 접근하려고 할 때 동작한다.
목적
예시
/user/profile
에 접근하면:DelegatedAuthenticationEntryPoint
가 호출되어 "401 Unauthorized" 응답을 반환.흐름
SecurityContext
에서 인증 정보를 확인.EntryPoint
호출 → 인증 절차로 안내.언제 동작하는가?
사용자가 인증은 되었지만, 요청한 리소스에 접근할 권한(Authorization)이 없을 때 동작
목적
예시
/admin
에 접근하면:DelegatedAccessDeniedHandler
가 호출되어 "403 Forbidden" 응답을 반환.흐름
Handler
호출 → "403 Forbidden" 응답 반환.EntryPoint:
Handler:
특징 | EntryPoint | Handler |
---|---|---|
목적 | 인증되지 않은 사용자 처리 | 권한이 없는 사용자 처리 |
동작 시점 | 인증(Authentication) 실패 시점 | 인가(Authorization) 실패 시점 |
반환 HTTP 상태 코드 | 401 Unauthorized | 403 Forbidden |
비유 | "신분증 없으면 못 들어가요!" | "VIP만 들어갈 수 있어요!" |
이제 다 알아보았으니 진짜 WebConfig 를 살펴보자.
@Configuration
@EnableWebSecurity // SecurityFilterChain 빈 설정을 위해 필요.
@RequiredArgsConstructor
public class WebConfig {
private final JwtAuthFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
private final AuthenticationEntryPoint authEntryPoint;
private final AccessDeniedHandler accessDeniedHandler;
private static final String[] WHITE_LIST = {"/accounts/login", "/accounts/join", "/favicon.ico",
"/error"};
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
...
}
@Bean
public RoleHierarchy roleHierarchy() {
...
}
방금 설명했던 EntryPoint 와 DeniedHandler 가 @Component 로 등록되어있기 때문에
그 둘을 주입받는 모습을 볼 수 있다.
그 외에도 Filter 로 적용할 JwtAuthFilter,
사용자 정보를 가져올 때 사용할 AuthenticationProvider 도 존재하는 모습이다.
또한 인증, 인가가 필요하지 않은 WHITE_LIST 도 따로 준비된 모습이다.
@EnableWebSecurity 어노테이션은 Spring Security를 활성화하고,
애플리케이션의 보안 설정을 정의하는 SecurityFilterChain Bean을 설정하기 위해 필요하다.
이 어노테이션은 Spring Boot 애플리케이션에서 Spring Security가 동작하도록 설정하는 중요한 역할을 하기 때문에 꼭 필요하다!
Spring Security는 모든 요청이 인증 필요로 설정된 기본 보안 설정을 제공한다.
@EnableWebSecurity를 사용하면, 기본 보안 설정을 사용자가 정의한 설정으로 대체할 수 있다.
이제 아래 두개의 메서드를 각각 알아보도록하자!!
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth ->
auth.requestMatchers(WHITE_LIST).permitAll()
// static 리소스 경로
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
// 일부 dispatch 타입
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.INCLUDE,
DispatcherType.ERROR).permitAll()
// path 별로 접근이 가능한 권한 설정
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/staff/**").hasRole("STAFF")
.requestMatchers("/user/**").hasRole("USER")
// 나머지는 인증이 필요
.anyRequest().authenticated()
)
// Spring Security 예외에 대한 처리를 핸들러에 위임.
.exceptionHandling(handler -> handler
.authenticationEntryPoint(authEntryPoint)
.accessDeniedHandler(accessDeniedHandler))
// JWT 기반 테스트를 위해 SecurityContext를 가져올 때 HttpSession을 사용하지 않도록 설정.
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(authenticationProvider)
/*
* Spring Security와 관련된 예외(AuthenticationException, AccessDeniedException)는
* ExceptionTranslationFilter에서 처리가 된다.
*
* ExceptionTranslationFilter의 doFilter()는 이후의 필터 체인에서 예외가 발생하면 그 예외를 처리하도록 작성되어 있다.
* request를 넘겨 JwtAuthFilter에서 발생한 예외를 처리시키기 위해 ExceptionTranslationFilter 다음에 수행하도록 순서를 설정.
*/
.addFilterAfter(jwtAuthFilter, ExceptionTranslationFilter.class);
return http.build();
}
SecurityFilterChain은 Spring Security의 핵심 구성 요소로,
HTTP 요청을 처리하는 필터들의 체인을 정의하는 역할을 수행한다.
이 securityFilterChain
메서드는 Spring Security의 보안 설정을 정의한 메서드다.
이 메서드는 HttpSecurity
객체를 사용하여 애플리케이션의 보안 정책(인증, 인가, 필터 설정 등)을 구성하고, 이를 기반으로 SecurityFilterChain 객체를 생성하여 반환한다.
보안 정책 정의:
요청별 접근 권한 설정.
인증 및 인가 실패 시 처리 핸들러 설정.
JWT 기반 인증을 위한 상태 관리 정책 정의.
필터 체인 구성:
요청이 처리될 때 어떤 필터들이 동작할지 정의.
JwtAuthFilter
를 필터 체인에 추가.
http.cors(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable);
CORS (Cross-Origin Resource Sharing):
CSRF (Cross-Site Request Forgery):
.authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST).permitAll()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.ERROR).permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/staff/**").hasRole("STAFF")
.requestMatchers("/user/**").hasRole("USER")
.anyRequest().authenticated());
WHITE_LIST
:
/accounts/login
), 회원가입(/accounts/join
) 등 특정 경로를정적 리소스 경로:
Dispatcher Type 허용:
경로별 권한 설정:
/admin/**
: 관리자(ADMIN
)만 접근 가능./staff/**
: 직원(STAFF
)만 접근 가능./user/**
: 일반 사용자(USER
)만 접근 가능.기본 정책:
authenticated()
).exceptionHandling(handler -> handler
.authenticationEntryPoint(authEntryPoint)
.accessDeniedHandler(accessDeniedHandler));
authenticationEntryPoint
:
accessDeniedHandler
:
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
SessionCreationPolicy.STATELESS
:.authenticationProvider(authenticationProvider);
authenticationProvider
:DaoAuthenticationProvider
).UserDetailsService
) 및 비밀번호 검증(PasswordEncoder
)을 처리..addFilterAfter(jwtAuthFilter, ExceptionTranslationFilter.class);
JwtAuthFilter
:
ExceptionTranslationFilter
이후에 실행되도록 설정.왜 ExceptionTranslationFilter
이후에 추가하는가?
ExceptionTranslationFilter
는 인증 및 인가 예외를 처리하는 필터.JwtAuthFilter
에서 발생한 예외를 처리하도록 흐름을 설정.이 메서드는 Spring Security의 보안 설정을 정의한다.
요청별 권한 설정, JWT 인증 필터 추가, Stateless 환경 설정, 예외 처리 핸들러 등록 등을 통해 애플리케이션의 보안을 강화한다.
핵심 설정:
JwtAuthFilter
를 통해 인증 처리.SecurityFilterChain 을 다 알아봤따!!!!
RoleHieracy는 나중에 필요할 때 다시 공부해봐야겠다. 그리 어렵지도 않은 듯?
이제 이 정리 글을 여러번 읽어보면서 흐름을 더 명확하게 이해하고 구현을 시작해야겠따
진짜 끗