[개인 프로젝트] Spring Security 구조 및 Spring Security 관련 문제 발생

Turtle·2024년 9월 5일
0

개인 프로젝트 기록

목록 보기
10/15

Spring Security

Spring Security란, 인증(Authentication)/인가(Authorization)를 처리하는 기능을 제공하는 프레임워크이다.

Spring Security 특징

  • Filter 기반으로 동작한다.
    • Spring MVC와 분리
  • Bean으로 설정할 수 있다.
    • XML 설정 불필요

Spring Security 구조

👉1. HTTP Request

사용자가 로그인 정보(아이디, 패스워드)와 함께 인증 요청을 한다.

👉2. Role을 기반으로 토큰 생성

Filter(AuthenticationFilter)에서 요청을 가로채 UsernamePasswordAuthenticationToken을 생성한다.

👉3. 만들어진 UsernamePasswordAuthenticationTokenAuthenticationManager에 위임

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

👉UsernamePasswordAuthenticationToken 생성자를 보면 첫 번째 생성자의 경우 super 키워드를 사용하고 있는데 결과적으로 메인 생성자는 두 번째 생성자라는 것을 알 수 있다. unauthenticated() 메서드 호출 시 인증이 성공적으로 완료되지 않았을 때 호출되는 첫 번째 생성자가 호출된다.
👉authenticated() 메서드 호출 시 인증이 성공적으로 완료된 경우 호출되는 두 번쨰 생성자가 호출된다.

AuthenticationManager

👉위는 AuthenticationManager 인터페이스 코드이다.
👉만약에 커스텀으로 작성한 코드를 등록하려면 AuthenticationProvider 인터페이스를 구현한 클래스를 이 AuthenticationManager에 등록하면 된다.

👉위는 AuthenticationManager 인터페이스를 구현한 클래스인 ProviderManager 코드의 일부이다. authenticate() 메서드 내부를 보면 등록된 AuthenticationProviderIterator로 순회하면서 적합한 AuthenticationProvider를 사용하여 인증을 처리하게 된다.

AuthenticationProvider

👉실제 인증에 대한 부분을 처리한다. 대표적으로 DaoAuthenticationProvider가 있다.
👉인증 전에 Authentication 객체를 받아 인증이 완료된 객체를 AuthenticationManager에 반환하는 역할을 한다.

UserDetailsService

👉UserDetails 객체를 반환하는 loadUserByUsername() 메서드를 가지고 있다.
👉MemberRepository를 주입받아 DB와 연결하여 처리한다.

UserDetails

UserDetailsService에서 사용자 정보와 DB 정보를 비교해 반환된 UserDetailsAuthentication 인터페이스를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용된다.

👉문제 상황

DB에 사용자 정보가 존재하지 않는 상황에서는 UsernamePasswordNotException 예외가 발생하길 원했고 DB에 사용자 정보가 존재하는 상황에서 사용자 정보를 잘못 입력한 경우에는 BadCredentialsException 예외가 발생하길 원했다.

하지만 실제로는 두 경우 모두 다 BadCredentialsException 예외가 발생했다.

Spring Security에서는 기본적으로 DaoAuthenticationProvider를 사용하고 있다.

DaoAuthenticationProviderAbstractUserDetailsAuthenticationProvider를 상속받는 구조로 되어 있었다.

try문에서 retriveUser() 메서드를 호출했을 떄 예외가 발생하면 catch문으로 이동하게 된다. 여기서 !this.hideUserNotFoundException에서 만약 true라면 UsernameNotFoundException 예외가 발생하게 되고 만약 false라면 BadCredentialsException 예외가 발생하게 된다.

그러나 기본적으로 Spring Security에서는 this.hideUserNotFoundException 값이 true이기에 원했던 UsernameNotFoundException 예외 대신 BadCredentialsException 예외가 발생했던 것이다.

구글링을 해보면 이에 대한 해결 방법으로 setHideUserNotFoundExceptions 설정 값을 false로 해주면 된다고 나와 있다. 그렇게 되면 this.hideUserNotFoundExceptionfalse가 되고 이를 반전시키게 되면 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);
	}
}

0개의 댓글