[Spring Security] 기존의 일반/소셜 로그인 로직을 순수 Filter만 이용하게 리팩토링

김태훈·2023년 10월 28일
0

Spring Security

목록 보기
5/7
post-thumbnail

manually 하게 Authentication을 만들지 않고, 로그인 로직을 작성하려면 어떻게 해야할까?
이는 Spring Security의 아키텍쳐를 잘 이해할 필요가 있다.
https://velog.io/@goat_hoon/Spring-Security를-활용한-JWT-도입기

1. 일반 로그인 로직을 처리해주는 Filter

링크한 포스트 중에 UsernamePasswordAuthenticationFilter가 있다.
그 중 일부 코드를 발췌해보겠다.

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

	private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
			"POST");

	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

	private boolean postOnly = true;

	public UsernamePasswordAuthenticationFilter() {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
	}

	public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}
    
    @Nullable
	protected String obtainPassword(HttpServletRequest request) {
		return request.getParameter(this.passwordParameter);
	}

	/**
	 * Enables subclasses to override the composition of the username, such as by
	 * including additional values and a separator.
	 * @param request so that request attributes can be retrieved
	 * @return the username that will be presented in the <code>Authentication</code>
	 * request token to the <code>AuthenticationManager</code>
	 */
	@Nullable
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(this.usernameParameter);
	}

해당 필터는 SpringSecurityFilter를 적용시키면 자동으로 추가 되는 Security Filter이다.
일반 로그인의 과정을 Filter에게 위임시키는데 중추적인 역할을 한다.

주목할 부분은 딱 두 군데이다.
1. 기본 로그인의 end point는 '/login' 이다.
2. authentication 과정을 거치기 위해 'username', 'password'가 필요하다.

저 username password는 그럼 어떻게 받을 수 있을까?
obtainUsername(HttpServletRequest request)
obtainPassword(HttpServletRequest request)
두가지 함수가 그 역할이다.
저 함수의 내용을 보면 반가운 함수가 보이는데, 바로 getParameter() 함수이다.
이 함수는 어느 곳에 쓰이는 함수인지 WebApplication을 공부한 사람이라면 알 수 있을 텐데, 바로 form-data 로 POST한 객체이거나, queryParameter로 요청한 경우에 데이터를 받는 함수이다.

즉, Security Filter를 동작시키기 위해서, 반드시 'form-data'로 username필드와 password 필드 안에 해당정보를 담아 보내야할 것이다. (쿼라파라미터로 개인 정보를 보내는건 미친짓이니 말이다.)

저 정보만 추가해주면, 내부적으로 알아서 인증 절차를 밟아 준다. (당연히 UserDetailsService를 따로 구현해야 한다)

하지만, 인증 실패와, 인증 성공 이후의 과정이 아직 담겨 있지 않다. (물론, 이 filter가 실행하기 위한 설정도 필요하다)
기존의 Controller에서 manually하게 인증과정을 거치고 결과 값을 response에 담아주었기 때문에 매우 간단했다.

하지만, 현재 과정은 Servlet에 request를 전달하기 이전의 Filter에서 일어나는 과정이다. 즉, Controller에서 HttpServletResponse의 객체를 이용하여 쉽게 response를 처리할 수 없다는 뜻이다.

이를 해결하기 위해서는 AbstractAuthenticationFilterConfigurer를 이용해야 한다.

2. 소셜 로그인 로직을 처리해주는 Filter

소셜로그인 관련 로직은 앞선 포스트에서 충분히 설명하였기 때문에 넘어가겠다.
https://velog.io/@goat_hoon/Spring-Security를-활용한-OAuth-적용기-Google

Security Config에 등록한 내용은 다음과 같다.
OAuthLogin 관련 Filter설정 추가하기 위한 설정들이다.

.oauth2Login(oauth2 -> oauth2
    .clientRegistrationRepository(clientRegistrationRepository)
    .userInfoEndpoint(it -> it.userService(oAuthService))
    .successHandler(authenticationSuccessHandler))

여기서 주목할 부분은,
인증 성공로직이 일반 로그인과 소셜로그인이 동일하다는 사실만 주목해서 다음 내용을 읽으면 될것 같다.

3. 로그인 로직과 그 성공 실패를 처리하기

우리는 SecurityConfig를 이용하여, 여러가지 Security 설정을 적용시킨 SecurityFilterChain을 등록한 적이 있다.

우리가 앞서 살펴본 UsernamePasswordAuthenticationFilter를 성공적으로 적용시키려면 SecurityFilterChain에 이를 알려야 한다.

먼저 전체 코드부터 보자.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
    	.formLogin((formLogin) -> formLogin
        	.loginProcessingUrl("/users/login")
            .failureHandler(nonSocialLoginFailureHandler)
            .successHandler(authenticationSuccessHandler).permitAll())

코드를 읽기만 해도 어느정도 감이 올것이다.

htttpSecurityFilterChain에 formLogin 설정을 추가하여 UsernamePasswordAuthenticationFilter가 동작하게 한다.

4. 인증 로직을 위한 추가적인 설정

AbstractAuthenticationFilterConfigurer
위 객체가 인증 및 인증 성공로직과 실패로직을 담는다.

위 사진은 상속관계를 담을 다이어그램이다.
즉 SecurityFilter에 여러가지 설정을 추가적으로 할 수 있게 한다.
username 파라미터 이름이라던지, 성공시 redirect할 URL이라든지 이거 바꾸고 싶은데 될까? 싶은건 정말 다 된다.

1) 일반 로그인에서의 login URL 설정

AbstractAuthenticationFilterConfigurer 를 활용하여 loginURL을 임의로 설정할 수 있다.
기본으로 UsernamePasswordAuthenticationFilter필터에서 loginURL의 end point가 '/login' 으로 되어있다.
우리 프로젝트에서는 프론트엔드의 추가적인 라우팅 엔드포인트의 변화를 막기 위해서, 이전 엔드포인트 Login URL에 맞추어 Security설정을 변경하였다.

2) 인증 성공과 실패 처리를 담당할 failureHandler, successHandler

AbstractAuthenticationFilterConfigurer 에는 successHandler와 failureHandler 설정이 존재한다.

public final T successHandler(AuthenticationSuccessHandler successHandler) {
	this.successHandler = successHandler;
    return getSelf();
}

이런식으로 AuthenticationSuccessHandler를 등록해서 사용할 수 있다.그렇다면 해당 객체를 추가적으로 만들어서 security config에 적용시켜야 한다.

3) 구현한 성공/실패 handler

1. Failure

AuthenticationFailureHandler를 구현하였다.

@Component
public class NonSocialLoginFailureHandler implements AuthenticationFailureHandler {

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

    private void makeFailureResponseBody(HttpServletResponse response) throws IOException {
        String failureResponse = convertFailureObjectToString();
        response.setStatus(response.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(failureResponse);
    }

    private String convertFailureObjectToString() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        IsSuccessResponseDto isSuccessResponseDto = new IsSuccessResponseDto(false, "아이디 또는 비밀번호를 잘못 입력하였습니다.");
        String successResponse = objectMapper.writeValueAsString(isSuccessResponseDto);
        return successResponse;
    }
}

Security Config에서 사용할 수 있게 Component Scan의 대상으로 지정하고, 다음과 같이 response를 직접 만들어서 클라이언트에 반환하게 하였다.
이렇게 한 이유는 Controller를 거치지 않기 떄문이다.

2. Success

@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
    private final TokenProvider tokenProvider;
    @Value("${jwt.domain}") private String domain;
    @Value("${oauth-signup-uri}") private String signUpURI;
    @Value("${oauth-signin-uri}") private String signInURI;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException {
        String accessToken = tokenProvider.createAccessToken(authentication);
        String refreshToken = tokenProvider.createRefreshToken(authentication);

        /**
         * 일반 로그인일 경우 생성되는 Authentication 객체를 상속한 UsernamePasswordAuthenticationToken 으로 response 생성
         * 바로 jwt 토큰 발급하여 response 에 쿠키를 추가합니다.
         */
        if (authentication instanceof UsernamePasswordAuthenticationToken){
            makeSuccessResponseBody(response);
            resolveResponseCookieByOrigin(request, response, accessToken, refreshToken);
            return;
        }

        resolveResponseCookieByOrigin(request, response, accessToken, refreshToken);
        response.sendRedirect(redirectUriByFirstJoinOrNot(authentication));

    }

    private static void makeSuccessResponseBody(HttpServletResponse response) throws IOException {
        String successResponse = convertSuccessObjectToString();
        response.setStatus(response.SC_OK);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(successResponse);
    }

    private static String convertSuccessObjectToString() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        IsSuccessResponseDto isSuccessResponseDto = new IsSuccessResponseDto(true, "로그인에 성공하였습니다.");
        String successResponse = objectMapper.writeValueAsString(isSuccessResponseDto);
        return successResponse;
    }

    private void resolveResponseCookieByOrigin(HttpServletRequest request, HttpServletResponse response, String accessToken, String refreshToken){
        if(request.getServerName().equals("localhost") || request.getServerName().equals("dev.inforum.me")){
            addCookie(accessToken, refreshToken, response,false);
        }
        else{
            addCookie(accessToken, refreshToken, response,true);
        }
    }

    private void addCookie(String accessToken, String refreshToken, HttpServletResponse response,boolean isHttpOnly) {
        String accessCookieString = makeAccessCookieString(accessToken, isHttpOnly);
        String refreshCookieString = makeRefreshCookieString(refreshToken, isHttpOnly);
        response.setHeader("Set-Cookie", accessCookieString);
        response.addHeader("Set-Cookie", refreshCookieString);
    }

    private String makeAccessCookieString(String token,boolean isHttpOnly) {
        if(isHttpOnly){
            return "accessToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=3600; SameSite=Lax; HttpOnly; Secure";
        }else{
            return "accessToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=3600;";
        }
    }

    private String makeRefreshCookieString(String token,boolean isHttpOnly) {
        if(isHttpOnly){
            return "refreshToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=864000; SameSite=Lax; HttpOnly; Secure";
        }else{
            return "refreshToken=" + token + "; Path=/; Domain=" + domain + "; Max-Age=864000;";
        }
    }

    private String redirectUriByFirstJoinOrNot(Authentication authentication){
        OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = oAuth2User.getAuthorities();
        if(authorities.stream().filter(o -> o.getAuthority().equals(Role.OAUTH_FIRST_JOIN)).findAny().isPresent()){
            return UriComponentsBuilder.fromHttpUrl(signUpURI)
                    .path(authentication.getName())
                    .build().toString();

        }
        else{ // non social 로그인의 경우 회원가입한 유저이므로 else문으로 항상 들어감.
            return UriComponentsBuilder.fromHttpUrl(signInURI)
                    .build().toString();
        }
    }
}

성공로직은 당연하게도 설명할 것이 많다.

이는 각 서비스의 특징에 따라 매우 다른 양상이 될 것이다.

일단 우리 서비스의 특징은 다음과 같다.

  1. 일반 로그인
    회원가입을 할 때, 유저 닉네임의 중복검사를 받고, 회원가입이 된다.

  2. 소셜 로그인
    소셜 로그인 인증 이후에, 회원가입을 시킨 후 닉네임 설정 페이지가 뜬다. 이 때문에 redirect URL이 존재했다.

이 두가지 로그인의 존재, 그리고 로그인 이후의 처리 과정의 차이 때문에 좀 복잡해졌다.

1) onAuthenticationSuccess

먼저 인증이 성공하면, Authentication객체에 로그인별로 다른 Authentication 구현체가 들어온다.
두가지 로그인의 가장 큰 차이는, Authentication 객체의 인스턴스의 종류가 다르다는 것이다.
일반 로그인은 UsernamePasswordAuthenticationToken,
소셜 로그인은 OAuth2AuthenticationToken이다.

이를 통해 두가지 다른 로그인 이후의 로직을 처리하였다.


2) redirect시 user Role에 따른 다른 URL설정

여기서는 간단히 설명하고 다음 포스트에서 구현내용과 함께 자세히 설명하겠다.

먼저 일반 로그인의 회원가입의 경우는 닉네임과 회원가입이 한꺼번에 이뤄지기 때문에, 회원가입을 하는 순간 유저의 ROLE을 'USER'로 설정하였다.

하지만, 소셜로그인의 경우, 우리 DB에 해당 소셜로그인의 정보가 있는지 체크를 하는 과정이 필요하다.
있다면, 그 사람은 이미 우리쪽의 해당 소셜 계정으로 회원가입이 되어있는 사람이므로 'USER'의 권한을 가지고 있는 사람이다.
하지만 그렇지 않은경우에는 우리 서비스에 최초로 소셜로그인을 한 유저이므로, '최초 회원가입'이다.

이를 구별하기 위해, 최초로 로그인한 유저에게는 임시로 'OAUTH_FIRST_JOIN'의 역할을 부여하였다. (다음 포스트에서 알아봄)

그래서, 해당 ROLE을 부여받은 유저의 경우에는 닉네임 등록 페이지로 이동할 것이며, 그렇지 않은 경우에는 기존의 서비스 페이지로 이동할 것이다.

조금 혼동이 있을까봐 다시 정리하자면,
최초로 소셜계정으로 로그인 한 경우에 DB에 'USER'역할로 ROLE이 설정되는 것은 맞다.
다만 최초 로그인의 경우에 '임시'로 OAUTH_FIRST_JOIN 역할을 부여하는 것 뿐이다. 이를 통해서 로그인 이후의 로직을 분기하기 위해서이다.

다음 포스트에서 유저 ROLE을 설정한 과정을 설명하겠다.

profile
기록하고, 공유합시다

0개의 댓글