[메모장 프로젝트] JWT 기반 일반/소셜 로그인 구현하기

728x90

 

전체 아키텍처 개요

이 시스템은 다음과 같은 특징을 가진다.

  • JWT 기반 토큰 인증 (`Access Token` + `Refresh Token`)
  • 일반 로그인소셜 로그인 통합 지원
  • 게스트 사용자 지원 및 회원 전환 시 데이터 이관
  • 쿠키 기반 토큰 저장으로 XSS 공격 방어
  • Redis 를 활용한 Refresh Token 관리

핵심 컴포넌트 구조

1. PrincipalMember - 통합 사용자 정보 객체

public class PrincipalMember implements UserDetails, OAuth2User {
    private final Member member;
    private final Map<String, Object> attributes;
    
    // 일반 로그인용 생성자
    public PrincipalMember(Member member) {
        this.member = member;
        this.attributes = Map.of();
    }
    
    // 소셜 로그인용 생성자
    public PrincipalMember(Member member, Map<String, Object> attributes) {
        this.member = member;
        this.attributes = attributes;
    }
}

핵심포인트

  • `UserDetails` 와 `OAuth2User` 인터페이스를 동시에 구현
  • 일반 로그인과 소셜 로그인을 하나의 객체로 통합 처리
  • 생성자 오버로딩으로 각 로그인 방식에 맞는 초기화 지원

2. JWT 토큰 관리 시스템

: JwtProvider - 토큰 생성 및 관리

public class JwtProvider {
    // 토큰 생성
    public String createAccessToken(String name, String loginId, String role)
    public String createRefreshToken(String loginId)
    
    // 토큰 검증 및 파싱
    public JwtPrincipal createMemberFromToken(String token)
    public boolean isExpiredToken(String token)
    
    // 쿠키 관리
    public Cookie createCookie(TokenName name, String token)
    public String extractTokenFromCookies(Cookie[] cookies, TokenName name)
}

보안 고려사항

  • 쿠키에 `HttpOnly`, `Secure` 플래그 설정
  • 토큰 만료 시간에 여유분(3분) 추가하여 쿠키와 토큰 만료 시점 동기화

3. 인증 필터 및 성공 핸들러

: JwtFilter - 요청별 인증 처리

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
    String path = request.getRequestURI();
    if (isPublicPath(path)) {
        filterChain.doFilter(request, response);
        return;
    }

    AuthStatus status = jwtAuthenticationService.authenticateStatus(request, response);
    
    if (status == AuthStatus.SUCCESS) {
        filterChain.doFilter(request, response);
        return;
    }

    if (status == AuthStatus.NO_TOKEN) {
        response.sendRedirect("/login");
        return;
    }
            
    response.sendRedirect("/login?error=expired");
}

 

: CustomAuthenticationSuccessHandler - 로그인 성공 처리

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    PrincipalMember principalMember = (PrincipalMember) authentication.getPrincipal();
    JwtPrincipal jwtPrincipal = jwtLoginSuccessProcessor.createJwtPrincipal(principalMember);
    
    jwtLoginSuccessProcessor.reissueTokensAndAuthenticate(request, response, jwtPrincipal);
    getRedirectStrategy().sendRedirect(request, response, "/");
}

토큰 갱신 매커니즘

Access Token과 Refresh Token 전략

  1. Access Token: 짧은 만료 시간 (보통 15분~1시간)
  2. Refresh Token: 긴 만료 시간 (보통 1주~1개월), `Redis` 에 저장

자동 토큰 갱신 플로우

private AuthStatus handleAccessToken(String accessToken, String refreshToken, HttpServletResponse response) {
    if (jwtProvider.isExpiredToken(accessToken)) {
        // Access Token이 만료된 경우
        if (refreshToken != null && !jwtProvider.isExpiredToken(refreshToken)) {
            // Refresh Token이 유효한 경우 새로운 Access Token 발급
            if (authenticateWithRefreshToken(response, refreshToken)) {
                return AuthStatus.SUCCESS;
            }
        }
        return AuthStatus.TOKEN_EXPIRED;
    }
    
    // Access Token이 유효한 경우 바로 인증
    if (authenticateWithAccessToken(accessToken)) {
        return AuthStatus.SUCCESS;
    }
    return AuthStatus.INVALID_TOKEN;
}

장점

  • 사용자는 토큰 만료를 인식하지 못함
  • 보안성과 사용자 경험의 균형점 확보

게스트 사용자 시스템

게스트 → 회원 전환 시 데이터 이관

private void transferGuestDataIfExists(HttpServletRequest request, JwtPrincipal jwtPrincipal) {
    String guestToken = jwtProvider.extractTokenFromCookies(request.getCookies(), TokenName.ACCESS_TOKEN);
    if (guestToken != null) {
        JwtPrincipal guest = jwtProvider.createMemberFromToken(guestToken);
        
        if (guest != null && guest.getRole() == Role.ROLE_GUEST) {
            // 게스트 데이터를 회원 계정으로 이관
            guestService.transferGuestMemos(guest, jwtPrincipal.getUsername());
            redisService.deleteRefreshToken(guest.getUsername());
        }
    }
}

핵심 아이디어

  • 게스트로 생성한 데이터가 회원가입 후에도 유지
  • 사용자 경험을 해치지 않음

Redis 를 활용한 토큰 관리

Refresh Token 저장 및 검증

// 토큰 저장
redisService.saveRefreshToken(username, refreshToken, expiry);

// 토큰 검증
String savedRefreshToken = redisService.getRefreshTokenByLoginId(loginId);
if (!refreshToken.equals(savedRefreshToken)) {
    return false; // 토큰 불일치
}

// 토큰 삭제
redisService.deleteRefreshToken(guest.getUsername());

보안 효과

  • 서버에서 `Refresh Token` 을 무효화할 수 있음
  • 중복 로그인 방지 또는 허용 정책 구현 가능
  • 로그아웃 시 즉시 토큰 무효화

로그아웃 처리

public void processSuccess(HttpServletRequest request, HttpServletResponse response) {
    String accessToken = jwtProvider.extractTokenFromCookies(request.getCookies(), TokenName.ACCESS_TOKEN);
    JwtPrincipal jwtPrincipal = jwtProvider.createMemberFromToken(accessToken);
    
    // Redis에서 Refresh Token 삭제
    redisService.deleteRefreshToken(jwtPrincipal.getUsername());
    
    // 쿠키 만료 처리
    response.addCookie(jwtProvider.expireCookie(TokenName.ACCESS_TOKEN));
    response.addCookie(jwtProvider.expireCookie(TokenName.REFRESH_TOKEN));
}

 

 

 

 

 

 

 

728x90