~☆~ 우하하!!~ 개발블로그

[연재] SpringBoot diary - 로그인 실패 사유 표시하기 본문

SpringBoot

[연재] SpringBoot diary - 로그인 실패 사유 표시하기

iwoohaha 2024. 11. 15. 10:27
반응형

diary 프로그램을 테스트하던 중에 내가 실수로 이메일 주소를 잘못 입력했었어.

haha 가 아닌 hahaha 를 입력한거지.

그런데, 웹주소에 login?error 라고만 표시되고 왜 로그인이 안되는건지에 대한 이유가 표시되질 않는거야.

그래서 이번에는 로그인 실패시 사유를 표시하는 기능을 구현해보려고 해.

실패 사유 확인

실패 사유를 보여주려면 실패 사유부터 확인해야되겠지? 현재 로그인처리를 담당하고 있는 구현 코드가 LoginService 클래스에 아래와 같이 작성되어 있는 상태야.

@Service
public class LoginService implements UserDetailsService {
    ...
    private final MemberMapper memberMapper;
    ...
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberMapper.GetMember(username).orElseThrow();
        return new User(member);
    }
}

단순하게 username 에 해당하는 사용자정보를 가져오되, 가져오는게 실패하면 throw 하도록 처리가 되어 있는데, 가져온 값을 확인해서 있는 경우와 없는 경우를 나눠보려고 해.

MemberMapper 인터페이스의 GetMember 함수는 진작부터 Optional<Member> 를 리턴하고 있기 때문에, 이 값을 활용해서 아래와 같이 수정했어.

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<Member> member = memberMapper.GetMember(username);
        if (member.isEmpty())
            throw new UsernameNotFoundException("해당 이름의 사용자가 존재하지 않습니다: " + username);
        return new User(member.get());
    }

수정하기 전에는 사용자계정이 존재하지 않는 경우 InternalAuthenticationServiceException 예외가 발생했는데, 사용자계정이 존재하지 않을 경우에 명확하게 UsernameNotFoundException 이 발생하도록 수정된거지.

이제 사용자계정 조회시 발생한 Exception 을 처리하는 로직을 구현해볼 차례야.

인증이 실패하는 경우 처리할 핸들러를 만들어야 해. config 패키지 아래에 CustomAuthFailureHandler 클래스를 만들고 SimpleUrlAuthenticationFailureHandler 로부터 상속받도록 하자.

package com.woohahaapps.study.diary.config;

import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;

@Component
public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
}

이 핸들러는 Spring Boot 에 의해서 관리되어야 하기 때문에 @Component 애노테이션을 붙여야 한다는 사실을 잊지마.

SimpleUrlAuthenticationFailureHandler 클래스 내부로 들어가보면 알겠지만, AuthenticationFailureHandler 인터페이스의 구현 클래스야.

public class SimpleUrlAuthenticationFailureHandler implements AuthenticationFailureHandler {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private String defaultFailureUrl;
    private boolean forwardToDestination = false;
    private boolean allowSessionCreation = true;
    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    public SimpleUrlAuthenticationFailureHandler() {
    }

    public SimpleUrlAuthenticationFailureHandler(String defaultFailureUrl) {
        this.setDefaultFailureUrl(defaultFailureUrl);
    }

    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        if (this.defaultFailureUrl == null) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace("Sending 401 Unauthorized error since no failure URL is set");
            } else {
                this.logger.debug("Sending 401 Unauthorized error");
            }

            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        } else {
            this.saveException(request, exception);
            if (this.forwardToDestination) {
                this.logger.debug("Forwarding to " + this.defaultFailureUrl);
                request.getRequestDispatcher(this.defaultFailureUrl).forward(request, response);
            } else {
                this.redirectStrategy.sendRedirect(request, response, this.defaultFailureUrl);
            }

        }
    }

    protected final void saveException(HttpServletRequest request, AuthenticationException exception) {
        if (this.forwardToDestination) {
            request.setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception);
        } else {
            HttpSession session = request.getSession(false);
            if (session != null || this.allowSessionCreation) {
                request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", exception);
            }

        }
    }

    public void setDefaultFailureUrl(String defaultFailureUrl) {
        Assert.isTrue(UrlUtils.isValidRedirectUrl(defaultFailureUrl), () -> {
            return "'" + defaultFailureUrl + "' is not a valid redirect URL";
        });
        this.defaultFailureUrl = defaultFailureUrl;
    }

    protected boolean isUseForward() {
        return this.forwardToDestination;
    }

    public void setUseForward(boolean forwardToDestination) {
        this.forwardToDestination = forwardToDestination;
    }

    public void setRedirectStrategy(RedirectStrategy redirectStrategy) {
        this.redirectStrategy = redirectStrategy;
    }

    protected RedirectStrategy getRedirectStrategy() {
        return this.redirectStrategy;
    }

    protected boolean isAllowSessionCreation() {
        return this.allowSessionCreation;
    }

    public void setAllowSessionCreation(boolean allowSessionCreation) {
        this.allowSessionCreation = allowSessionCreation;
    }
}

이 중에서 onAuthenticationFailure 함수를 CustomAuthFailureHandler 클래스에 오버라이딩해 줄거야.

@Component
public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        super.onAuthenticationFailure(request, response, exception);
    }
}

onAuthenticationFailure 함수는 인증이 실패한 경우에 호출되는 함수인데, 인증 실패시에는 여러가지 종류의 예외가 터지지. 이 예외들을 종류별로 처리해서 적절한 에러메시지를 작성할 계획이야.

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        String errorMessage;

        if (exception instanceof BadCredentialsException) {
            errorMessage = "아이디 또는 비밀번호가 맞지 않습니다. 다시 확인해 주세요.";
        } else if (exception instanceof InternalAuthenticationServiceException) {
            errorMessage = "내부적으로 발생한 시스템 문제로 인해 요청을 처리할 수 없습니다. 관리자에게 문의하세요.";
        } else if (exception instanceof UsernameNotFoundException) {
            errorMessage = "존재하지 않는 계정입니다. 회원가입 후 로그인해 주세요.";
        } else if (exception instanceof AuthenticationCredentialsNotFoundException) {
            errorMessage = "인증요청이 거부되었습니다. 관리자에게 문의하세요.";
        } else {
            errorMessage = "알 수 없는 이유로 로그인에 실패하였습니다. 관리자에게 문의하세요.";
        }
        errorMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); /* 한글 인코딩 깨진 문제 방지 */
        setDefaultFailureUrl("/login?error=true&message=" + errorMessage);

        super.onAuthenticationFailure(request, response, exception);
    }

인증실패시 발생할 수 있는 예외의 종류는 위 코드에 기록한 것들이야. 이 중에 loadUserByUsername 함수에서 발생시킨 UsernameNotFoundException 도 처리되고 있지.

이제 이 인증실패 처리 핸들러를 Spring Security 에 등록시킬 차례야.

Spring Seuciry 의 폼 로그인에서 인증처리를 담당하고 있기 때문에 formLogin 함수 안에 failureHandler 를 등록해주도록 하고 있어.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomAuthFailureHandler customAuthFailureHandler;

    public SecurityConfig(CustomAuthFailureHandler customAuthFailureHandler) {
        this.customAuthFailureHandler = customAuthFailureHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// swagger 에서는 PUT, POST, DELETE method 를 위해서 필수(필수아님)로 기록해야 한다.
                        .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
                )
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/process_login"
                                , "/signup"
                                , "/swagger-ui.html"
                                , "/swagger-ui/**"
                                , "/v3/api-docs/**"
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .loginProcessingUrl("/process_login")
                        .usernameParameter("email")
                        .failureHandler(customAuthFailureHandler)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutSuccessUrl("/login")
                        .invalidateHttpSession(true)
                )
                .httpBasic(Customizer.withDefaults())// swagger 에서는 필수로 기록해야 한다.
                .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

인증과정에서 실패가 발생하면 예외가 터지고, 이 예외는 커스터마이징된 CustomAuthFailureHandler 의 onAuthenticationFailure 에서 처리되고, 실패시 URL 에 message 를 포함해서 던지도록 구성했어.

이제 /login URL 에서 message 를 표시하도록 수정할 차례야.

LoginController 의 /login URL 핸들러에서 error 와 message 파라미터를 받아서 UI template 로 전달되도록 처리했어.

@Controller
public class LoginController {

    @GetMapping("/login")
    public String login(@RequestParam(value="error", required = false) String error, @RequestParam(value="message", required = false) String message, Model model) {
        model.addAttribute("error", error);
        model.addAttribute("message", message);
        return "login";
    }
    ...
}

login.html 에서는 이 파라미터를 받아서 표시해 주어야지.

...
<main class="form-signin w-100 m-auto">
    <th:block th:if="${error}">
        <div th:if="${message}" class="alert alert-danger" role="alert" th:text="${message}"></div>
    </th:block>
    <form th:action="@{/process_login}" method="POST">
        <h1 class="h3 mb-3 fw-normal">Please sign in</h1>

        <div class="form-floating">
            <input type="email" class="form-control" id="floatingInput" name="email" placeholder="name@example.com">
            <label for="floatingInput">Email address</label>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" id="floatingPassword" name="password" placeholder="Password">
            <label for="floatingPassword">Password</label>
        </div>

        <div class="form-check text-start my-3">
            <input class="form-check-input" type="checkbox" value="remember-me" id="flexCheckDefault">
            <label class="form-check-label" for="flexCheckDefault">
                Remember me
            </label>
        </div>
        <button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
        <p class="mt-5 mb-3 text-body-secondary">© 2017–2024</p>
    </form>
</main>
...

이제 프로그램을 빌드해서 실행했는데, 없는 계정을 입력하고 로그인해도 자꾸 login URL 로 표시만 되는거야.

그래서 인증없이 접근가능한 목록에 /login 도 추가했더니 잘 되더군.

SecurityConfig.java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// swagger 에서는 PUT, POST, DELETE method 를 위해서 필수(필수아님)로 기록해야 한다.
                        .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
                )
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/login", "/process_login"
                                , "/signup"
                                , "/swagger-ui.html"
                                , "/swagger-ui/**"
                                , "/v3/api-docs/**"
                        ).permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
...

멤버로 등록되지 않은 woohahaha@gmail.com 을 입력했을 때나 존재하지 않는 계정의 비밀번호를 일부러 틀리게 입력해서 로그인했을 때모두 아래와 같이 BadCredentialsException 예외시의 메시지가 표시되더라고.

왜, UsernameNotFoundException 을 발생시키고 있는데, 이 예외에 대한 처리가 되지 않는것인지 궁금해서 디버그모드로 Breakpoint 를 걸고 추적해보니, AbstractUserDetailsAuthenticationProvider.class 의 authenticate 함수에서 hideUserNotFoundExceptions 값이 true 라서 아래쪽의 BadCredentialsException 예외가 처리된거더라고.


그렇다면 hideUserNotFoundExceptions 값을 true 가 아닌 false 로 변경한다면 내가 의도한대로 UsernameNotFoundException 어 처리될 수 있겠네. 사실 이 방법은 보안적으로는 좋지 않다고 해. 그래서 디폴트값이 true인거야. 다만 어디까지나 학습을 위해서 수정을 해볼께.

수정할 수 있는 방법은 Spring Boot 에게 DaoAuthenticationProvider 객체를 제공하는건데, SecurityConfig 클래스에 아래와 같이 DaoAuthenticationProvider 객체를 주입되게 만드는거야.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomAuthFailureHandler customAuthFailureHandler;
    private final LoginService loginService;

    public SecurityConfig(CustomAuthFailureHandler customAuthFailureHandler, LoginService loginService) {
        this.customAuthFailureHandler = customAuthFailureHandler;
        this.loginService = loginService;
    }
...

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(loginService);
        authenticationProvider.setPasswordEncoder(passwordEncoder());
        authenticationProvider.setHideUserNotFoundExceptions(false);
        return authenticationProvider;
    }
}

DaoAuthenticationProvider 를 생성하고 setHideUserNotFoundExceptions 함수로 false 를 전달했어. 이 클래스는 사용자 인증 처리와 관련된 클래스이기 때문에 사용자정보를 조회하기 위한 LoginService 와 암호화 방법에 대한 설정을 해주고 있지.

이제 프로그램을 빌드해서 실행시킨 다음에 등록되지 않은 계정을 입력하고 로그인하면 아래와 같이 계정을 찾을 수 없다는 UsernameNotFoundException 예외가 정상적으로 처리되고 있음을 확인할 수 있어.

반응형