BookStory 2 - 로그인 기능

junto·2024년 4월 21일
0

spring

목록 보기
21/30
post-thumbnail

JWT 토큰 로그인

  • 기존 세션 로그인 방식과 달리 토큰 로그인 방식은 백엔드 서버에서 클라이언트 로그인 상태를 세션에 저장하지 않는다. 서버는 JWT 토큰만으로 사용자 인증 상태를 알 수 있기 때문에 확장성이 좋다.

  • 하지만 보안 측면에서 해당 토큰이 탈취되었을 경우를 대비하는 다중 토큰 발행 로직이 필요하다.

Access token과 Refresh token

  • Access token은 사용자가 인증 후 서비스나 자원에 접근할 수 있도록 허가하는 토큰으로 10분 내외의 짧은 유효기간을 가진다. Refresh token은 Access token이 만료된 후에 사용자가 Refresh token을 가지고 새로운 access token을 얻을 수 있는 긴 수명을 가진 토큰이다.
  • Refresh token으로 하여금 사용자는 다시 로그인 할 필요 없이 서비스 이용하는 경험을 개선할 수 있다. 자주 사용되는 Access 토큰이 탈취되더라도 생명주기가 짧아 손해를 끼치는 시간이 줄어든다.

만약에 Refresh token이 탈취된다면?

Refresh token blacklisting

  • 블랙리스팅은 리프레시 토큰이 도난당하거나 악의적으로 사용될 경우, 그 토큰을 사용 목록에서 제외하는 방법이다.
  • Access 토큰을 갱신하기 위한 Refresh 토큰 요청 시 서버 측에서 Refresh 토큰도 재발급을 진행하여 한 번 사용한 Refresh 토큰은 재사용하지 못하도록 한다. Refresh 토큰 탈취에 의해 피해가 진행되는 경우 서버 측 저장소에서 해당 JWT를 삭제하여 피해를 방어한다.

1. 로그인 인증 필터 재정의

1. attemptAuthentication

  • attemptAuthentication 재정의하여 회원 아이디와 비밀번호를 이용하여 인증되지 않은 토큰을 만든다. 이를 AuthenticationManager에 넘겨 토큰 검증을 시도한다. 이유는 사용자가 여러 인증 논리를 구현할 수 있으며(기본 로그인, 소셜 로그인) 인증 논리를 구현한 객체에 맞게 인증이 완료되면 인증된 토큰을 전달하기 위해서다.
  • 일반 로그인을 진행할 경우 DaoAuthenticationProvider에 의해 authenticate가 실행된다.
@Override
public Authentication attemptAuthentication(
    HttpServletRequest request,
    HttpServletResponse response
) throws AuthenticationException {

  RequestLogin requestLogin = readByJson(request, response);

  if (requestLogin == null) {
    return null;
  }

  UsernamePasswordAuthenticationToken authToken =
      new UsernamePasswordAuthenticationToken(requestLogin.email(), requestLogin.password());

  return authenticationManager.authenticate(authToken);
}
  • AuthenticationManager가 인증을 검증하기 위해서 UserDetailsService의 loadUserByUsername 메서드가 호출되어 데이터베이스에서 사용자 정보를 조회하고 이를 바탕으로 UserDetails 객체를 생성한다.
public class CustomUserDetailsService implements UserDetailsService {

  private final UserRepository userRepository;

  public CustomUserDetailsService(UserRepository userRepository) {
    this.userRepository = userRepository;
  }

  @Override
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    User user = userRepository.findByEmailAndIsExist(email, true).orElseThrow(
        UserNotExistException::new
    );

    return new CustomUserDetails(user);
  }
}
public class CustomUserDetails implements UserDetails {

  private final User user;

  public CustomUserDetails(User user) {
    this.user = user;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    Collection<GrantedAuthority> collection = new ArrayList<>();

    collection.add((GrantedAuthority) () -> user.getRole().name());

    return collection;
  }

  @Override
  public String getPassword() {
    return user.getPassword();
  }

  @Override
  public String getUsername() {
    return user.getUserName();
  }

  public String getId() {

    return String.valueOf(user.getId());
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }
}

2. successfulAuthentication

토큰 저장 위치

  • 로그인이 성공할 경우 successfulAuthentication에서 jwt 토큰을 발급하는 로직을 작성한다. access token은 헤더에 refresh token은 쿠키로 발급하였다. 해당 설정은 필수적이지 않다. 회사 정책을 따르거나 각 방법의 취약점을 알고 이를 예방한다.
  • 발급받은 토큰을 헤더로 반환하여 로컬 스토리지에 저장하는 것은 XSS 공격에 취약하며, 쿠키에 토큰을 저장하는 경우 CSRF 공격에 취약하다.
  • 액세스 토큰은 다양한 곳에서 사용할 수 있으며 사용 편의성을 위해 로컬 스토리지에 저장하기로 했다.
@Override
protected void successfulAuthentication(
    HttpServletRequest request,
    HttpServletResponse response,
    FilterChain chain,
    Authentication authResult
) throws IOException, ServletException {

  CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();

  String id = customUserDetails.getId();

  String role = customUserDetails.getAuthorities().iterator().next().getAuthority();

  ResponseCreateTokens tokens = createTokens(id, role);

  response.setHeader("access", tokens.accessToken());
  response.addCookie(
      cookieUtil.createCookie("refresh", tokens.refreshToken(),
          CookieUtil.REFRESH_TOKEN_EXPIRATION_TIME));
  response.setStatus(HttpStatus.OK.value());
}

2. 로그인 필터 설정

  • LoginFilter에서 attemptAuthentication, successfulAuthentication를 재정의하고 해당 로그인 필터를 기존 UsernamePasswordAuthenticationFilter에 위치시킨다.
http
    .addFilterAt(
        new LoginFilter(authenticationManager(authenticationConfiguration),
            jwtUtil, refreshRepository, cookieUtil),
        UsernamePasswordAuthenticationFilter.class);

3. Jwt 토큰 인증 필터

  • 사용자의 요청이 있을 때 이미 jwt 토큰을 요청과 함께 보냈고, 토큰이 유효하다면 다시 로그인을 거치지 않도록 인증 정보를 설정하는 필터가 필요하다.
public class JwtFilter extends OncePerRequestFilter {

  private final JwtUtil jwtUtil;

  public JwtFilter(JwtUtil jwtUtil) {
    this.jwtUtil = jwtUtil;
  }

  @Override
  protected void doFilterInternal(
      HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

    String accessToken = request.getHeader("access");

    if (accessToken == null) {
      filterChain.doFilter(request, response);
      return;
    }

    if (isInvalidAccessToken(accessToken, response)) {
      return;
    }

    setAuthenticationFromToken(jwtUtil, accessToken);

    filterChain.doFilter(request, response);
  }

  private void setAuthenticationFromToken(JwtUtil jwtUtil, String accessToken) {

    String id = jwtUtil.getId(accessToken);
    String role = jwtUtil.getRole(accessToken).replace("ROLE_", "");

    User user = new User(id, Role.valueOf(role));

    CustomUserDetails customUserDetails = new CustomUserDetails(user);

    UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
        customUserDetails, null, customUserDetails.getAuthorities());

    SecurityContextHolder.getContext().setAuthentication(authToken);
  }
  ...
}
  • Header에 Access token이 있다면 이를 검증하고, 유효하다면 SecurityContext에 Authentication 객체를 설정하고, 해당 객체는 인증 성공 정보를 가지고 있다.

jwt 인증 성공했을 때 Security Filter Chain은 어떻게 login filter에서 attempAtthentication()을 건너뛰는 것일까?

  • UsernamePasswordAuthenticationFilter가 상속하는 AbstractAuthenticationProcessingFilter를 살펴보면 이미 인증이 설정되었을 때 다음 필터로 넘어가는 로직이 있다.
	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if (!requiresAuthentication(request, response)) {
			chain.doFilter(request, response);
			return;
		}
		try {
			Authentication authenticationResult = attemptAuthentication(request, response);
			if (authenticationResult == null) {
				// return immediately as subclass has indicated that it hasn't completed
				return;
			}
			this.sessionStrategy.onAuthentication(authenticationResult, request, response);
			// Authentication success
			if (this.continueChainBeforeSuccessfulAuthentication) {
				chain.doFilter(request, response);
			}
			successfulAuthentication(request, response, chain, authenticationResult);
		}
	}

4. Jwt 토큰 필터 설정

  • Jwt Filter는 로인증된 사용자라면 재로그인 없이 어떤 기능을 이용할 수 있어야 한다. 따라서 로그인 필터 전에 위치시킨다.
http
    .addFilterBefore(
        new CustomLogoutFilter(jwtUtil, refreshRepository, cookieUtil), LogoutFilter.class);

지금까지 작성한 코드 개략도를 살펴보면 아래와 같다.

소셜 로그인

기본 용어

클라이언트(Client)

  • 리소스에 접근하려는 애플리케이션에 해당한다.

인증 서버(Authorization Server)

  • 인증 및 인가를 수행하는 서버로 리소스에 대한 액세스 토큰을 발행한다. 구글, 네이버는 이런 OAUTH2 로그인 서비스를 지원한다.

리소스 서버(Resource Server)

  • 리소스를 호스팅하는 서버로 액세스 토큰의 유효 여부를 확인하고 해당 리소스에 대한 접근을 허용한다. 예를 들어 구글에서 받은 액세스 토큰을 가지고 구글 캘린더 앱 정보를 가져올 수 있다. 여기서 구글 캘린더 앱은 리소스 서버이다.

인증 코드(Authorization Code)

  • 사용자가 로그인에 성공하고 나서 받는 코드이며 이후 액세스 토큰을 발행할 때 필요하다.

소셜 로그인 기본 동작

1. 구글 인증 서버 요청

http://localhost:8080/oauth2/authorization/google, 302 Found
  • 구글 인증 서버로 리다이렉트 된다. 이 과정에서 302 FOUND 상태 코드가 반환된다.

2. 구글 OAuth2 인증 URL 접근

  • 사용자가 로그인을 하며 URL 파라미터로 아래와 같은 정보를 요청한다.
https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=15623728038-co2cuhlfdt91missna6pvmlf56gc9fbq.apps.googleusercontent.com&scope=profile email&state=zDNWdpabKhsObwqWt81-b2DLief7xSxStXQmGD04F2Y%3D&redirect_uri=http://localhost:8080/login/oauth2/code/google
  • response_type: 액세스 토큰을 얻기 위한 인증 코드를 요청.
  • client_id: 애플리케이션을 식별하는 고유 ID로, 구글에 등록할 때 받는 ID.
  • scope: 애플리케이션이 요청하는 권한 범위. ex) 사용자 프로필과 이메일 정보
  • state: CSRF 공격을 방지하기 위해 사용되는 랜덤 문자열로, 리다이렉트 후에 반환되어 요청의 유효성을 확인하는 데 사용.
  • redirect_uri: 인증 과정이 완료된 후 사용자를 리다이렉트할 URI.

3. 인증 코드 반환

  • 사용자가 로그인하고 권한을 부여하면, Google은 redirect_uri로 사용자를 다시 리다이렉트하고 URL에 code(인증 코드)와 state 값을 포함시킨다.
state: zDNW2pxbKhxOfwqWt81-b2DL5ef7xSwatXQmGD04FjY=
code: 4/0AeaYSHBxs5NBkt5Ze2I8Yvbv27Wnbe5n9g-EbgK82F3sBwX5mE_VO5nNAPOtfEB972HWj0A
scope: email profile https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid
authuser: 0
prompt: none

4. 액세스 토큰 요청

  • Code 및 등록 정보로 액세스 토큰을 요청한다. 이 과정은 브라우저에서 보이지 않는 서버단 통신이다. 액세스 토큰이 직접 스프링 서버에 전달되는 이유는 토큰이 탈취되거나 오용될 위험을 줄이기 위함이다.
  • 위에서 받은 정보를 기반으로 구글 인증서버에서 Access token을 발급받는 API를 호출할 수 있다.

위 과정을 그림으로 나타내면 아래와 같다.

  • 변수 설정만 진행하면 OAuth2AuthorizationRequestRedirectFilter → OAuth2LoginAuthenticationFilter → OAuth2LoginAuthenticationProvider 까지의 과정을 추가 설정하지 않아도 자동으로 진행한다. 따라서 사용자는 UserDetailsService와 UserDetails만 구현하면 된다.

소셜 로그인 구현

1. 소셜 로그인 제공 서비스 정보 기입

  • ClientRegistration, 서비스별 OAuth2 클라이언트의 등록 정보를 가지는 클래스다.
@Component
public class SocialClientRegistration {

    public ClientRegistration googleClientRegistration() {

        return ClientRegistration.withRegistrationId("google")
                .clientId("아이디")
                .clientSecret("비밀번호")
                .redirectUri("http://localhost:8080/login/oauth2/code/google")
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .scope("profile", "email")
                .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth")
                .tokenUri("https://www.googleapis.com/oauth2/v4/token")
                .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
                .issuerUri("https://accounts.google.com")
                .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo")
                .userNameAttributeName(IdTokenClaimNames.SUB)
                .build();
    }
}
  • ClientRegistrationRepository, ClientRegistration의 저장소로 서비스별 ClientRegistration들을 가진다.
@Configuration
public class CustomClientRegistrationRepo {

    private final SocialClientRegistration socialClientRegistration;

    public CustomClientRegistrationRepo(SocialClientRegistration socialClientRegistration) {

        this.socialClientRegistration = socialClientRegistration;
    }

    public ClientRegistrationRepository clientRegistrationRepository() {

        return new InMemoryClientRegistrationRepository(socialClientRegistration.naverClientRegistration(), socialClientRegistration.googleClientRegistration());
    }
}
  • spring security 설정 파일에 해당 정보를 추가한다.
http
	.oauth2Login((oauth2) -> oauth2  	
    .clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
    .userInfoEndpoint((userInfoEndpointConfig) -> 
    	userInfoEndpointConfig.userService(customOAuth2UserService)));

2. 소셜 로그인 공급자로부터 사용자 정보 받아오기

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    //DefaultOAuth2UserService OAuth2UserService의 구현체

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println(oAuth2User.getAttributes());

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
       if (registrationId.equals("google")) {

            oAuth2Response = new GoogleReponse(oAuth2User.getAttributes());
        }
        else {
            return null;
        }
        
		***
    }
}

3. SecurityContextHolder에 인증 정보 저장하기

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    //DefaultOAuth2UserService OAuth2UserService의 구현체

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

		...
                
        // DB에 소셜 로그인 유저 정보 저장하기
        // DB에 유저 정보가 없다면 신규 추가, 있다면 갱신하는 로직
        
        return new CustomOAuth2User(oAuth2Response, role); 
    }
}
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {

        this.oAuth2Response = oAuth2Response;
        this.role = role;
    }

    @Override
    public Map<String, Object> getAttributes() {

        return null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {

            @Override
            public String getAuthority() {

                return role;
            }
        });

        return collection;
    }

    @Override
    public String getName() {

        return oAuth2Response.getName();
    }

    public String getUsername() {

        return oAuth2Response.getProvider()+" "oAuth2Response.getProviderId();
    }
}

참고자료

profile
꾸준하게

0개의 댓글