Spring Security란, 인증(Authentication)/인가(Authorization)를 처리하는 기능을 제공하는 프레임워크이다.
👉1. HTTP Request
사용자가 로그인 정보(아이디, 패스워드)와 함께 인증 요청을 한다.
👉2. Role을 기반으로 토큰 생성
Filter(
AuthenticationFilter
)에서 요청을 가로채UsernamePasswordAuthenticationToken
을 생성한다.👉3. 만들어진
UsernamePasswordAuthenticationToken
을AuthenticationManager
에 위임
AuthenticationManager
인터페이스의 구현체인ProviderManager
에게 생성한UsernamePasswordAuthenticationToken
을 넘겨준다.👉4.
AuthenticationProvider
의 목록으로 인증을 시도
AuthenticationManager
는 등록된AuthenticationProvider
들을 조회하여 인증을 처리하도록 한다.👉5. UserDetailsService
실제 DB에서 사용자 인증 정보를 가져오는
UserDetailsService
에 사용자 정보를 넘겨준다.
loadUserByUsername()
메서드를 호출하여 데이터베이스에서 넘겨받은 사용자 정보를 통해UserDetails
를 조회한다.👉6.
UserDetails
를 이용해User
객체에 대한 정보 탐색
loadUserByUsername()
메서드를 호출하여 찾은 사용자 정보인UserDetails
객체를 만든다.👉7.
UserDetailsService
에서UserDetails
를 만들어 이를 리턴한다.
AuthenticationProvider
들은UserDetails
를 넘겨 받아 사용자 정보를 비교한다.👉8. DB 정보와 사용자 정보를 비교해 일치한다면
Authentication
객체를 반환하고 일치하지 않는다면AuthenticationException
예외가 발생한다.👉9. 만들어진
Authentication
객체를AuthenticationFilter
로 보낸다.만들어진
Authentication
객체를AuthenticationFilter
에 반환한다.👉10. 만들어진
Authentication
객체를SecurityContextHolder
에 저장한다.
Authentication
객체를SecurityContextHolder
에 저장한다.
👉
UsernamePasswordAuthenticationToken
생성자를 보면 첫 번째 생성자의 경우super
키워드를 사용하고 있는데 결과적으로 메인 생성자는 두 번째 생성자라는 것을 알 수 있다.unauthenticated()
메서드 호출 시 인증이 성공적으로 완료되지 않았을 때 호출되는 첫 번째 생성자가 호출된다.
👉authenticated()
메서드 호출 시 인증이 성공적으로 완료된 경우 호출되는 두 번쨰 생성자가 호출된다.
👉위는
AuthenticationManager
인터페이스 코드이다.
👉만약에 커스텀으로 작성한 코드를 등록하려면AuthenticationProvider
인터페이스를 구현한 클래스를 이AuthenticationManager
에 등록하면 된다.
👉위는
AuthenticationManager
인터페이스를 구현한 클래스인ProviderManager
코드의 일부이다.authenticate()
메서드 내부를 보면 등록된AuthenticationProvider
를Iterator
로 순회하면서 적합한AuthenticationProvider
를 사용하여 인증을 처리하게 된다.
👉실제 인증에 대한 부분을 처리한다. 대표적으로
DaoAuthenticationProvider
가 있다.
👉인증 전에Authentication
객체를 받아 인증이 완료된 객체를AuthenticationManager
에 반환하는 역할을 한다.
👉
UserDetails
객체를 반환하는loadUserByUsername()
메서드를 가지고 있다.
👉MemberRepository
를 주입받아 DB와 연결하여 처리한다.
UserDetailsService
에서 사용자 정보와 DB 정보를 비교해 반환된 UserDetails
는 Authentication
인터페이스를 구현한 UsernamePasswordAuthenticationToken
을 생성하기 위해 사용된다.
DB에 사용자 정보가 존재하지 않는 상황에서는 UsernamePasswordNotException
예외가 발생하길 원했고 DB에 사용자 정보가 존재하는 상황에서 사용자 정보를 잘못 입력한 경우에는 BadCredentialsException
예외가 발생하길 원했다.
하지만 실제로는 두 경우 모두 다 BadCredentialsException
예외가 발생했다.
Spring Security에서는 기본적으로 DaoAuthenticationProvider
를 사용하고 있다.
DaoAuthenticationProvider
는 AbstractUserDetailsAuthenticationProvider
를 상속받는 구조로 되어 있었다.
try
문에서 retriveUser()
메서드를 호출했을 떄 예외가 발생하면 catch
문으로 이동하게 된다. 여기서 !this.hideUserNotFoundException
에서 만약 true
라면 UsernameNotFoundException
예외가 발생하게 되고 만약 false
라면 BadCredentialsException
예외가 발생하게 된다.
그러나 기본적으로 Spring Security에서는 this.hideUserNotFoundException
값이 true
이기에 원했던 UsernameNotFoundException
예외 대신 BadCredentialsException
예외가 발생했던 것이다.
구글링을 해보면 이에 대한 해결 방법으로 setHideUserNotFoundExceptions
설정 값을 false
로 해주면 된다고 나와 있다. 그렇게 되면 this.hideUserNotFoundException
이 false
가 되고 이를 반전시키게 되면 true
가 되면서 UsernameNotFoundException
예외가 발생하게 된다.
직접 AuthenticationProvider
구현체를 만들면 굳이 UserDetailsService
구현체를 이용해 사용자 정보를 읽어올 필요가 없다고 생각했다. 이미 프로젝트에 구현된 서비스 계층에서 사용자 정보를 읽어오면 되기 때문이다.
다만 이렇게 커스텀으로 구현할 경우 다른 보안 체크에 대한 부분을 수동으로 구현해야 할 수도 있다는 점은 명확하게 인지해야 한다.
PrincipalDetails
@Getter
public class PrincipalDetails implements UserDetails, OAuth2User {
private String email;
private String password;
private Role role;
private Map<String, Object> attributes;
// Spring Security 로그인시 사용
public PrincipalDetails(String email, String password, Role role) {
this.email = email;
this.password = password;
this.role = role;
}
// OAuth2.0 로그인시 사용
public PrincipalDetails(String email, Role role, Map<String, Object> attributes) {
this.email = email;
this.role = role;
this.attributes = attributes;
}
@Override
public String getUsername() {
return this.email;
}
@Override
public String getPassword() {
return this.password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collect = new ArrayList<GrantedAuthority>();
collect.add(new SimpleGrantedAuthority(this.getRole().getRole()));
return collect;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// OAuth 2.0
@Override
public String getName() {
return this.email;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
}
CustomAuthenticationFailureHandler
@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final SessionAuthenticationStrategy sessionAuthenticationStrategy = new NullAuthenticatedSessionStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
// 현재 세션이 존재하면 무효화
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
sessionAuthenticationStrategy.onAuthentication(null, request, response);
String errorMessage;
// 예외 유형에 따라 적절한 에러 메시지 설정
if (exception instanceof UsernameNotFoundException) {
errorMessage = "계정이 존재하지 않습니다. 회원가입 진행 후 로그인 해주세요.";
} else if (exception instanceof BadCredentialsException) {
errorMessage = "아이디 혹은 비밀번호를 잘못 입력했습니다.";
} else {
errorMessage = "알 수 없는 이유로 로그인에 실패하였습니다 관리자에게 문의하세요.";
}
// 에러 메시지를 URL 인코딩
errorMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8);
// 실패 URL 설정 (에러 메시지 포함)
setDefaultFailureUrl("/auth/login?error=true&exception="+errorMessage);
// 부모 클래스의 실패 처리 메서드 호출
super.onAuthenticationFailure(request, response, exception);
}
}
UserDetailsService를 작성하지 않고 MemberService에서 사용자를 찾을 수 있도록 별도의 메서드 추가
@Service
@RequiredArgsConstructor
public class MemberService {
// ...
public UserDetails findUserByEmail(String email) {
// 리포지토리에서 findByEmail 메서드로 계정 정보 조회하기
Member member = memberRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException("사용자 정보를 찾을 수 없습니다."));
return new PrincipalDetails(member.getEmail(), member.getPassword(), member.getRole());
}
}
CustomAuthenticationProvider
@Slf4j
@Component
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final MemberService memberService;
private final BCryptPasswordEncoder passwordEncoder;
public CustomAuthenticationProvider(BCryptPasswordEncoder passwordEncoder, MemberService memberService) {
this.passwordEncoder = passwordEncoder;
this.memberService = memberService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
try {
UserDetails member = memberService.findUserByEmail(username);
if (!passwordEncoder.matches(password, member.getPassword())) {
log.error("사용자 정보를 다시 체크하세요.");
throw new BadCredentialsException("");
}
PrincipalDetails principalDetails = (PrincipalDetails) member;
return new UsernamePasswordAuthenticationToken(principalDetails, password, principalDetails.getAuthorities());
} catch (UsernameNotFoundException e) {
log.error("해당 이메일명의 사용자 정보를 찾을 수 없습니다.");
throw e;
}
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}