[토이프로젝트] 감정일기장-1 : 로그인 프로세스

onlydev7777·2024년 9월 11일
post-thumbnail

1️⃣ 로그인 요청(/login) 프로세스

로그인 프로세스

1. LoginRequestFilter

  • 로그인 요청(/login)에 대한 인증을 수행하는 필터
  • 미인증 상태의 Authentication 을 생성 후 LoginAuthenticationProvider 에 인증 수행 위임
  • 인증 성공 시 SecurityContext에 Authentication 저장
  @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException, IOException, ServletException {

      LoginRequest loginRequest = new ObjectMapper().readValue(request.getReader(), LoginRequest.class);
      LoginAuthentication unauthenticated = LoginAuthentication.unauthenticated(loginRequest);

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

2. LoginAuthenticationProvider

  • LoginRequestFilter 로 부터 미인증 상태의 Authentication 을 파라미터로 받아 ID, Password 추출
  • 추출한 ID 정보를 갖고 LoginService에 계정정보 확인 위임
  • 유효한 계정이면 인증 Authentication을 생성해서 LoginRequestFilter 로 다시 반환
  @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
      String idAndSocialType = AuthenticationUtil.toIdAndSocialType(authentication.getPrincipal());
      MemberDetails memberDetails = (MemberDetails) service.loadUserByUsername(idAndSocialType);

      String credentials = (String) authentication.getCredentials();

      if (!passwordEncoder.matches(credentials, memberDetails.getPassword())) {
        throw new BadCredentialsException("Password Not Matches!");
      }

      return LoginAuthentication.authenticated(Payload.of(memberDetails), List.of());
    }

3. LoginService

  • 입력받은 ID 정보로 DB에 질의
  • 유효한 계정이라면 MemberDetails 생성해서 LoginAuthenticationProvider 로 다시 반환
   @Transactional(readOnly = true)
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      String id = username.split("\\^")[0];
      SocialType type = SocialType.valueOf(username.split("\\^")[1]);

      return MemberDetails.of(
          repository.findByUserIdAndSocialType(id, type).orElseThrow(
              () -> new UsernameNotFoundException(id + " Not Found")
          )
      );
    }

4. AbstractAuthenticationProcessingFilter

  • Spring Security 기본 제공 추상 인증 클래스
  • 인증 성공 시, SecurityContext에 인증 상태의 Authentcation 저장
  • 인증 성공 시, AuthenticationSuccessHandler 호출
  • 인증 실패 시, AuthenticationFailureHandler 호출
  protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
              Authentication authResult) throws IOException, ServletException {
          SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
          context.setAuthentication(authResult);
          this.securityContextHolderStrategy.setContext(context);
          this.securityContextRepository.saveContext(context, request, response);
          if (this.logger.isDebugEnabled()) {
              this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
          }
          this.rememberMeServices.loginSuccess(request, response, authResult);
          if (this.eventPublisher != null) {
              this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
          }
          this.successHandler.onAuthenticationSuccess(request, response, authResult);
      }

5. LoginSuccessHandler

  • 인증 성공 처리 핸들러
  • access-token, refresh-token 생성
  • 생성된 토큰 Redis 서버에 save
    • refresh-token 요청 시 redis 서버에서 유효성 검증
  • 클라이언트에 최종 Response 응답
    • Status : 200
    • Header : {Authorization:access-token, Refresh-Token:refresh-token}
    • Cookie : {Refresh-Token:refresh-token}
    • Body : LoginResponse JSON
  @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
        throws IOException, ServletException {
      Payload payload = TokenUtil.getPayload();
      String accessToken = jwtProvider.createToken(payload);
      String refreshToken = jwtProvider.refreshToken(payload.getRedisKey());
      Jwt jwt = new Jwt(accessToken, refreshToken);

      redisService.accessTokenSave(payload.getRedisKey(), jwt.getAccessToken());
      redisService.refreshTokenSave(payload.getRedisKey(), jwt.getRefreshToken());

      int refreshTokenMaxAge = (int) jwtProvider.getRefreshExpirationTime() / 1000;

      Cookie refreshTokenCookie = CookieUtil.createCookie(
          jwtProvider.getRefreshTokenHeader(),
          URLEncoder.encode(refreshToken, StandardCharsets.UTF_8),
          refreshTokenMaxAge,
          true,
          false,
          "/"
      );

      CookieUtil.addCookie(response, refreshTokenCookie);

      response.setStatus(HttpStatus.OK.value());
      response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);

      response.setHeader(jwtProvider.getAccessTokenHeader(), jwtProvider.getTokenPrefix() + jwt.getAccessToken());
      response.setHeader(jwtProvider.getRefreshTokenHeader(), jwtProvider.getTokenPrefix() + jwt.getRefreshToken());
      LoginResponse loginResponse = new LoginResponse(jwt, payload.getId());
      PrintWriter writer = response.getWriter();
      writer.println(new ObjectMapper().writeValueAsString(loginResponse));
      writer.flush();
      writer.close();
    }

6. LoginFailureHandler

  • 인증 실패 처리 핸들러
  • 401 UNAUTHORIZED 응답
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
        throws IOException, ServletException {
      response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
      response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      PrintWriter writer = response.getWriter();
      writer.write(exception.getMessage());
      writer.flush();
      writer.close();
    }

2️⃣ 클라이언트 Token 저장 방식

😭 Worst Case

1. localStorage 로 관리

  • XSS 에 취약
    • 악성스크립트로 localStoarge 접근 가능
  • XSS 에 취약
    • 악성스크립트로 Cookie 접근 가능
    • HttpOnly 설정 되어 있더라도 다른 API 요청에 대한 XSS 방어 불가
  • CSRF 에 취약
    • 저장된 Cookie 로 인해 모든 서버 인증 가능
  • CSRF에 취약
    • 저장된 Cookie 로 인해 모든 서버 인증 가능

😄 Best Case

Access-Token : Local Variables
Refresh-Token : HttpOnly Cookie

Access-Token 은 브라우저 로컬 변수에 저장하고 Refresh-Token 은 HttpOnly Cookie에 저장한다.
이렇게 하면 XSS, CSRF 모두 방어가 가능하다.

로그인 요청 Submit 코드

 const {response, error, fetchData} = useApi('/login', 'post');

 const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    const apiData = {
      userId: formData.get('id'),
      password: formData.get('password'),
      socialType: 'NONE'
    };

    //로그인 요청
    fetchData({method: 'post', data: apiData});
  };

로그인 완료 useEffect

  • acces-token 은 axiosInstance.defaults.headers.common['Authorization'] 로컬 변수에 저장
  • refres-token 은 서버에서 HttpOnly 쿠키로 전송
  useEffect(() => {
    //로그인 요청이 정상이면 메인페이지로 이동
    if (response && response.status === 200) {
      axiosInstance.defaults.headers.common['Authorization'] = response.headers.authorization;
      localStorage.setItem("id", response.data.id);
      setLoginSuccess(true);
      setAuthChecked(true);
      nav("/", {replace: true});
      return;
    }
    //로그인 요청 오류 메시지
    if (error) {
      alert("[" + error.response.status + "] " + error.response.data);
      return;
    }
  }, [response, error]);

3️⃣ 로그인 연장 프로세스

클라이언트에서 Refresh-Token URL을 통해 새로운 access-token 을 발급해서 로그인 연장 기능 제공

✅ Refresh-Token 호출 케이스

1. API 요청 -> Access-Token 만료 응답 -> POST refresh-token 요청 -> 다시 API 요청

useApi.jsx : axios 공통 커스텀 Hook

const useApi = (defaultUrl, defaultMethod = 'get', defaultOptions = {}) => {
  const [response, setResponse] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);
  const nav = useNavigate();

  const refreshToken = async () => {
    try {
      const response = await axiosInstance.post('/auth/refresh-token');
      const accessToken = response.headers.authorization;
      axiosInstance.defaults.headers.common["Authorization"] = accessToken;
      localStorage.setItem("id", response.data.id);
    } catch (err) {
      localStorage.removeItem("id");
      alert("로그인 유효시간이 지났습니다. 재로그인 바랍니다.");
      nav("/signin", {replace: true});
      throw err;
    }
  };

  const fetchData = async ({
    url = defaultUrl,
    method = defaultMethod,
    data = null,
    headers = {},
    params = {}
  } = {}) => {
    setLoading(true);
    setError(null);
    setResponse(null);

    try {
      const response = await axiosInstance({
        url,
        method,
        data,
        headers,
        params,
        ...defaultOptions,
      });
      setResponse(response);
    } catch (err) {
      if (err.response && err.response.status === 401 && err.response.data
          === 'Access-Token is expired') {
        try {
          await refreshToken();
          const retryResponse = await axiosInstance({
            url,
            method,
            data,
            headers,
            params,
            ...defaultOptions,
          });
          setResponse(retryResponse);
        } catch (error) {
          setError(error)
        }
      } else {
        setError(err);
      }
    } finally {
      setLoading(false);
    }
  };

  return {response, error, loading, fetchData};
};

2. 브라우저 새로고침 -> 로그인 상태 검증 -> POST refresh-token 요청 -> 페이지 Reload

App.jsx

useEffect(() => {
    const checkAuthStatus = async () => {
      let id = localStorage.getItem('id');
      const accessToken = axiosInstance.defaults.headers.common['Authorization'];

      if (id && !accessToken) { // id는 있지만, accessToken이 설정되지 않았을 때
        try {
          const response = await axiosInstance.post('/auth/refresh-token');
          axiosInstance.defaults.headers.common["Authorization"] = response.headers.authorization; // 새로운 access-token
          localStorage.setItem('id', response.data.id);
          setLoginSuccess(true);
        } catch (error) {
          console.error('Token refresh failed:', error);
          localStorage.removeItem("id");
          alert("로그인 유효시간이 지났습니다. 재로그인 바랍니다.");
          nav("/signin", {replace: true});
          return;
        }
      }

      setAuthChecked(true); // 인증이 끝나면 authChecked를 true로 설정
    };

    checkAuthStatus(); // 컴포넌트가 마운트될 때 체크
  }, []);

POST refresh-token API 서버 코드

  1. Refresh-Token 유효성 검증
    - Redis 서버 확인
  2. 사용자 정보 확인
  3. Payload 생성 및 로그인 인증 Authentication 생성
  4. 인증 상태의 Authentication 객체 SecurityContext에 저장
  5. LoginSuccessHandler 호출
    - 새로운 Access-Token, Refresh-Token 발급
    - 로그인 최초 인증과 동일한 프로세스 적용해서 응집도 높인다.
  @PostMapping("/refresh-token")
  public void refreshToken(HttpServletRequest request, HttpServletResponse response)
      throws ServletException, IOException {
    //1. validate refresh token
    String refreshToken = CookieUtil.getCookieValue(request, jwtProvider.getRefreshTokenHeader());
    String requestRefreshToken = jwtProvider.resolveToken(refreshToken);
    String redisKey = jwtProvider.verifyRefreshToken(requestRefreshToken);
    String findRefreshToken = redisService.refreshTokenGet(redisKey);
    if (!requestRefreshToken.equals(findRefreshToken)) {
      throw new JwtException("Invalid refresh token");
    }

    //2. find Member
    Long memberId = Long.parseLong(redisKey.split("/")[0]);   //get MemberId
    Member member = memberRepository.findById(memberId)
        .orElseThrow();

    //3. create payload
    Payload payload = Payload.of(MemberDetails.of(member));

    //4. set new token and refresh token to response header
    LoginAuthentication refreshAuthentication = LoginAuthentication.authenticated(payload, List.of());
    SecurityContextHolder.getContext().setAuthentication(refreshAuthentication);
    new LoginSuccessHandler(redisService, jwtProvider).onAuthenticationSuccess(request, response, refreshAuthentication);
  }

★ GitHub URL

front-end : https://github.com/onlydev7777/emotion-diary-react
back-end : https://github.com/onlydev7777/emotion-diary-monolithic

profile
https://github.com/onlydev7777

0개의 댓글