본문 바로가기

Proj/Tripot

application/json 형식으로 관리자 로그인 구현하기

1. 개요

해당 프로젝트에서 관리자 페이지를 만들어 운영하기로 했다. 운영하게 될 서비스는 소셜 로그인만 지원하도록 했지만 관리자 페이지는 username/password 형식으로 구현하도록 할 것이다. 해당 프로젝트는 스프링 시큐리티를 사용하므로 필터를 사용하여 로그인을 구현하고자 한다.

2. 사용한 코드

2-1. LoginFilter

public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private static final String DEFAULT_LOGIN_REQUEST_URL = "/api/v1/login/admin";  // /login 으로 오는 요청을 처리할 것이다
    private static final String HTTP_METHOD = "POST";    //HTTP 메서드의 방식은 POST 이다.
    private static final String CONTENT_TYPE = "application/json";//json 타입의 데이터로만 로그인을 진행한다.
    private final ObjectMapper objectMapper;



    private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD); //=>   /login 의 요청에, POST로 온 요청에 매칭

    public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {

        super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);  

        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {

        //json type이 아닌 경우 예외처리
        if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)  ) {
            throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
        }

        //json을 파싱하여 user 인증 진행
        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

        LoginDto loginDto = objectMapper.readValue(messageBody, LoginDto.class);

        /**
         * {
         *      "username": "username",
         *      "password": "password"
         */
        String username = loginDto.username();
        String password = loginDto.password();

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);//principal 과 credentials 전달

        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

필터 코드 전문이다. /api/v1/login/admin 경로로 오는 POST 요청에 대해 username과 password를 받아 토큰을 생성하고, 이를 기반으로 인증을 진행한다.

2-2. LoginSuccessHandler

@Slf4j
@RequiredArgsConstructor
public class LoginSuccessJwtProviderHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final RedisUtil redisUtil;


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {

        UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();

        setResponse(response, StatusCode.ADMIN_LOGIN_SUCCESS);
        addToken(response, principal);

    }


    private void addToken(HttpServletResponse response, UserPrincipal principal) {


        Member member = principal.getMember();

        LoginCreateJwtDto loginCreateJwtDto = LoginCreateJwtDto.builder()
                .id(member.getId())
                .username(member.getUsername())
                .role(member.getRole().toString())
                .requestTimeMs(LocalDateTime.now())
                .build();

        String accessToken = jwtUtil.createJwt(loginCreateJwtDto, "access");
        String refreshToken = jwtUtil.createJwt(loginCreateJwtDto, "refresh");
        log.info("[{}} JWT 토큰 생성 access: {}, refresh: {}", Thread.currentThread().getStackTrace()[1].getClassName(), accessToken, refreshToken);

        //redis에 refreshToken 저장하기((key, value): (token, username))
        //Bearer을 포함하지 않음
        redisUtil.setDataExpire(refreshToken, member.getUsername(), 15778800);


        //응답에 JWT 추가
        response.addHeader("Authorization", "Bearer " + accessToken);
        response.addHeader("Refresh_token", "Bearer " + refreshToken);
        log.info("[{}} 응답 헤더에 토큰 담기", Thread.currentThread().getStackTrace()[1].getClassName());

    }

    private void setResponse(
            HttpServletResponse response,
            StatusCode statusCode
    ) {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(statusCode.getHttpCode());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        CommonResponse errorResponse = CommonResponse.success(statusCode, null);
        try {
            log.info("[{}] admin login success, code: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), statusCode);
            response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

현 프로젝트는 JWT 방식의 인증을 진행한다. 이에 따라 로그인 성공 시 JWT 토큰을 만들어 헤더에 담고, refresh 토큰을 redis에 저장한다.

 

2-3. LoginFailureHandler

@Slf4j
public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {

        setErrorResponse(response, StatusCode.ADMIN_LOGIN_FAILURE);
        
    }

    private void setErrorResponse(
            HttpServletResponse response,
            StatusCode statusCode
    ) {
        ObjectMapper objectMapper = new ObjectMapper();
        response.setStatus(statusCode.getHttpCode());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        CommonResponse errorResponse = CommonResponse.fail(statusCode);
        try {
            log.error("[{}] Login fail, code: {}", Thread.currentThread().getStackTrace()[1].getMethodName(), statusCode);
            response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

로그인 실패 시의 내역을 응답 바디에 담아 반환하도록 한다.

 

3. SecurityConfig

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

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();

        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());

        return daoAuthenticationProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {//AuthenticationManager 등록
        DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
        JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
        jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
        jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
        jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
        return jsonUsernamePasswordLoginFilter;
    }

    @Bean
    public LoginSuccessJwtProviderHandler loginSuccessJWTProvideHandler(){
        return new LoginSuccessJwtProviderHandler(jwtUtil, redisUtil);
    }

    @Bean
    public LoginFailureHandler loginFailureHandler(){
        return new LoginFailureHandler();
    }

위에서 만든 클래스들을 스프링 빈으로 등록해야 한다. 로그인 필터를 보면 다음 코드가 있다.

return this.getAuthenticationManager().authenticate(authRequest);

해당 코드를 동작시키도록 커스텀한 필터를 등록하는 과정들이다. 

 

4. 여담

사실 컨트롤러에 있을 코드를 좀 풀어 쓴 느낌이다. 코드가 좀 복잡해보이게 적었는데 다 이해하지 못한 느낌이다. 다음 게시글에서는 authenticate가 어떻게 동작하는 지 알아볼 예정이다.