본문 바로가기

spring

Jlaner 개발기록 3 로그인 구성 (Oauth2, JWT, security)

이번 포스팅은 저번 포스팅에 이어 로그인 로직의 개요를 알아보고 레디스를 통해 토큰을 관리하는 구성을 했기 때문에 이번 포스팅에서는 전반적인 보안 설정과 어떤 보안 설정을 했는지를 알아볼 것이다.

우선 토큰을 발급하는 부분과 사용하는 부분에 대해 알아볼 것이다. 토큰에 관련되어서는 jwt 토큰 사용에 대해 포스팅한 부분에서 자세히 알 수 있다.

 

JwtProperties.class

@Setter
@Getter
@Component
@ConfigurationProperties("jwt")
public class JwtProperties {
    private String secretKey;
}

환경 설정인 application.yml에서 secretkey를 간편히 사용하기 위해 만든 클래스이다.

 

TokenProvider.class

package com.jlaner.project.config.jwt;


import com.jlaner.project.domain.Member;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Collections;
import java.util.Date;
import java.util.Set;

@RequiredArgsConstructor
@Service
public class TokenProvider {
    private final JwtProperties jwtProperties;

    /**
     * JWT 토큰 생성 메서드
     *
     * @param member      JWT에 포함할 사용자 정보
     * @param expiredAt 토큰의 유효 기간
     * @return 생성된 JWT 토큰
     */
    public String generateToken(Member member, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), member);
    }

    /**
     * JWT 토큰을 실제로 생성하는 메서드
     *
     * @param expiry 만료 시간
     * @param member   JWT에 포함할 사용자 정보
     * @return 생성된 JWT 토큰
     */
    private String makeToken(Date expiry, Member member) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("test")
                .setIssuedAt(now)
                .setExpiration(expiry)
                .setSubject(member.getName())
                .claim("id", member.getId())
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    /**
     * 주어진 JWT 토큰의 유효성을 검사하는 메서드
     *
     * @param token 검사할 JWT 토큰
     * @return 토큰의 유효성 여부 (유효하면 true, 그렇지 않으면 false)
     */
    public Boolean validToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parserBuilder()
                    .setSigningKey(jwtProperties.getSecretKey())
                    .build()
                    .parseClaimsJws(token);
            return claims.getBody()
                    .getExpiration()
                    .after(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * JWT 토큰에서 인증 객체를 가져오는 메서드
     *
     * @param token JWT 토큰
     * @return 인증 객체 (Spring Security의 UsernamePasswordAuthenticationToken)
     */
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("USER"));

        return new UsernamePasswordAuthenticationToken(
                new org.springframework.security.core.userdetails.User(claims.getSubject(), "", authorities),
                token,
                authorities);
    }

    /**
     * JWT 토큰에서 사용자 ID를 가져오는 메서드
     *
     * @param token JWT 토큰
     * @return 사용자 ID
     */
    public Long getMemberId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    /**
     * 주어진 JWT 토큰에서 클레임(Claims)을 추출하는 메서드
     *
     * @param token JWT 토큰
     * @return 추출된 클레임 객체
     */
    public Claims getClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(jwtProperties.getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

 

토큰 발급을 담당하는 부분이다. 토큰을 발급하고 클레임을 추출하고 유효기간을 검증하는 로직이 포함되어 있다.

토큰 발급과 사용 로직은 만들었으니 다음으로는 Oauth2 로그인을 위한 로직을 확인 후 securityconfig를 확인하자.

 

로그인해서 DB에 저장할 엔티티는 이전에 포스팅에 있는 Member가 엔티티로 저장을 진행하도록 하고 구글, 네이버, 카카오를 저장할 클래스가 필요하다. Oauth2 구성의 대부분은 이전에 Oauth2 포스팅에서 사용한 부분과 많이 일치하니 다른 포스팅을 확인해도 좋다.

package com.jlaner.project.config.details;

import com.jlaner.project.config.outh2.Oauth2UserInfo;
import lombok.AllArgsConstructor;

import java.util.Map;

@AllArgsConstructor
public class GoogleUserDetails implements Oauth2UserInfo {
    private Map<String, Object> attributes;
    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}
package com.jlaner.project.config.details;

import com.jlaner.project.config.outh2.Oauth2UserInfo;
import lombok.AllArgsConstructor;

import java.util.Map;

@AllArgsConstructor
public class KakaoUserDetails implements Oauth2UserInfo {
    private Map<String, Object> attributes;
    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attributes.get("id").toString();
    }

    @Override
    public String getEmail() {
        return (String) ((Map) attributes.get("kakao_account")).get("email");
    }

    @Override
    public String getName() {
        return (String) ((Map) attributes.get("properties")).get("nickname");
    }
}
package com.jlaner.project.config.details;

import com.jlaner.project.config.outh2.Oauth2UserInfo;
import lombok.AllArgsConstructor;

import java.util.Map;

@AllArgsConstructor
public class NaverUserDetails implements Oauth2UserInfo {
    private Map<String, Object> attributes;
    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return (String) ((Map) attributes.get("response")).get("id");
    }

    @Override
    public String getEmail() {
        return (String) ((Map) attributes.get("response")).get("email");
    }

    @Override
    public String getName() {
        return (String) ((Map) attributes.get("response")).get("name");
    }
}

 

package com.jlaner.project.config.details;

import com.jlaner.project.domain.Member;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

public class CustomOauth2UserDetails implements UserDetails, OAuth2User {
    private final Member member;

    private Map<String, Object> attributes;


    public CustomOauth2UserDetails(Member member, Map<String, Object> attributes) {
        this.member = member;
        this.attributes = attributes;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }
    @Override
    public String getName() {
        return null;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return member.getRole().name();
            }
        });
        return collection;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return member.getLoginId();
    }

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

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

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

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

 

이전에 Oauth2 로그인 구현할 때 사용했던 것을 그대로 사용했다.

 

package com.jlaner.project.config.outh2;

import com.jlaner.project.config.details.CustomOauth2UserDetails;
import com.jlaner.project.config.details.GoogleUserDetails;
import com.jlaner.project.config.details.KakaoUserDetails;
import com.jlaner.project.config.details.NaverUserDetails;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.MemberRole;
import com.jlaner.project.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOauth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);
        log.info("getAttributes : {}", oAuth2User.getAttributes());
        String provider = userRequest.getClientRegistration().getRegistrationId();

        Oauth2UserInfo oauth2UserInfo = null;

        if (provider.equals("google")){
            log.info("구글 로그인");
            oauth2UserInfo = new GoogleUserDetails(oAuth2User.getAttributes());
        } else if (provider.equals("kakao")) {
            log.info("카카오 로그인");
            oauth2UserInfo = new KakaoUserDetails(oAuth2User.getAttributes());
        } else if (provider.equals("naver")) {
            log.info("네이버 로그인");
            oauth2UserInfo = new NaverUserDetails(oAuth2User.getAttributes());
        }

        String providerId = oauth2UserInfo.getProviderId();
        String email = oauth2UserInfo.getEmail();
        String loginId = provider + "_" + providerId;
        String name = oauth2UserInfo.getName();

        Member findMember = memberRepository.findByLoginId(loginId);
        Member member;

        if (findMember == null) {
            member = Member.builder()
                    .loginId(loginId)
                    .name(name)
                    .email(email)
                    .provider(provider)
                    .providerId(providerId)
                    .role(MemberRole.USER)
                    .build();
            memberRepository.save(member);
        } else {
            member = findMember;
        }
        return new CustomOauth2UserDetails(member, oAuth2User.getAttributes());
    }
}

 

각 로그인해 DB에 저장하는 로직도 같다.

Handler나 filter 부분을 확인해야 하지만 우선 securityconfig 부분부터 확인해 보자.

package com.jlaner.project.config;


import com.jlaner.project.config.jwt.TokenAuthenticationFilter;
import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.config.outh2.CustomAuthenticationEntryPoint;
import com.jlaner.project.config.outh2.CustomOauth2UserService;
import com.jlaner.project.config.outh2.OAuth2AuthorizationRequestBasedOnCookieRepository;
import com.jlaner.project.config.outh2.OAuth2SuccessHandler;
import com.jlaner.project.repository.MemberRepository;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.RefreshTokenRedisService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final TokenProvider tokenProvider;
    private final MemberRepository memberRepository;
    private final MemberService memberService;
    private final CustomOauth2UserService customOauth2UserService;
    private final RefreshTokenRedisService refreshTokenRedisService;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    @Bean
    public WebSecurityCustomizer configure() { // 스프링 시큐리티 기능 비활성화
        return (web) -> web.ignoring()
//                .requestMatchers(toH2Console())
                .requestMatchers(
                        new AntPathRequestMatcher("/img/**"),
                        new AntPathRequestMatcher("/css/**"),
                        new AntPathRequestMatcher("/js/**")
                );
    }

    // HTTP 보안 설정을 구성하는 메서드
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
         http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests(auth -> auth
                        .requestMatchers(
                                new AntPathRequestMatcher("/login/**"),
                                new AntPathRequestMatcher("/pngs/**"),
                                new AntPathRequestMatcher("/error/**"),
                                new AntPathRequestMatcher("/favicon.ico"),
                                new AntPathRequestMatcher("/h2-console/**"),
                                new AntPathRequestMatcher("/actuator/**"))
                        .permitAll() // 위 경로들은 인증 없이 접근 가능
                        .requestMatchers(
                                new AntPathRequestMatcher("/api/**")
                        )
                        .authenticated()
                        .anyRequest()
                        .permitAll())
                .oauth2Login(login -> login
                        .loginPage("/login")
                        .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository()))
                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOauth2UserService))
                        .successHandler(oAuth2SuccessHandler())
                )
                 .exceptionHandling(exceptionHandling -> exceptionHandling
                         .authenticationEntryPoint(customAuthenticationEntryPoint));


         return http.build();
    }

    @Bean
    public OAuth2SuccessHandler oAuth2SuccessHandler() {
        return new OAuth2SuccessHandler(tokenProvider,
                refreshTokenRedisService,
                oAuth2AuthorizationRequestBasedOnCookieRepository(),
                memberService
        );
    }


    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(tokenProvider,refreshTokenRedisService,memberRepository);
    }


    @Bean
    public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
        return new OAuth2AuthorizationRequestBasedOnCookieRepository();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

시큐리티 기능 비활성화한 부분과 permitAll한 부분을 먼저 확인해 보자.

비활성화한 부분에서 toH2console이 주석처리가 되어 있는데 h2가 아닌 mysql로 접속해서 테스트할 게 있어 의존성을 주석처리 했기 때문에 잠시 꺼두어서 주석처리 했다.

img, css, js는 웹 페이지 구성을 위해 보안처리가 되지 않아야 하기 때문에 제외했다.

 

다음은 필터체인 부분에 csrf와 httpBasic, sessionManagement를 확인해 보자.

폼로그인은 사용하지 않기 때문에 disable 했고 csrf(Cross-Site Request Forgery)는 악의적인 웹사이트가 사용자의 브라우저를 통해 인증된 웹 애플리케이션에 비정상적인 요청을 보내는 공격을 의미한다. Spring Security는 기본적으로 CSRF 보호를 활성화하여 공격을 방지한다. disable 하지 않으면 공격을 방지해 주는데 굳이 disable 하는 이유는 API 기반으로 애플리케이션을 구성할 것이기 때문에 상태를 유지하지 않는다. 이는 Restful하기 때문이고 인증 토큰을 통해 보호되기 때문에 기능은 꺼두었다. 테스트 환경이나 개발 환경에서는 복잡성을 증가시킬 수 있으니 개발이나 테스트 시 꺼두고 배포 시에는 RestfulAPI를 사용하지 않는다면 켜는 것이 좋다.
이어서 sessionManagement도 disable 하는 이유로는 위와 일맥상통한다. 무상태를 유지하고 세션을 사용하지 않고 토큰을 통해 인증을 하기 때문에 세션기능도 disable 했다. 다음으로 확인해 볼 것은 httpBasic이다.

Basic 인증 방식은 말 그대로 가장 기본적인 인증 방식이다. 인증 정보로 사용자 ID, 비밀번호를 사용한다. base64로 인코딩한 “사용자ID:비밀번호” 문자열을 Basic과 함께 인증 헤더에 입력한다. 더 자세한 내용은 RFC 7617에 정의되어 있다. 간단하게 장점이지만 권한을 정교하게 제어할 수 없다.

http 인증 방식은 Basic과 Bearer가 있는데 Basic을 disable 하고 Bearer를 통해 Oauth2에서 사용하는 토큰 인증 방식을 사용하려 한다.

액세스 토큰으로 사용하거나 16진수 문자열로 사용하기도 한다. 안전하고 확장이 쉽다는 장점이 있다. 인증 방식에 대해서 다루는 포스팅이 아니기 때문에 이만 줄이고 다음 내용으로 진행하도록 하겠다.

데이터를 주고받을 땐 기본 경로를 /api로 정했기 때문에 기본적으로 인증을 해야 하는 경로를 뒀다.

oauth2 로그인으로 쿠키를 사용하고 유저 정보를 저장하고 성공 시 실행할 핸들러를 설정했다. 유저 정보 저장하는 로직은 확인했으니 보안 설정 클래스를 확인하고 나서 쿠키를 사용하는 클래스와 핸들러 클래스를 확인해 보도록 하겠다.

다음으로는 exceptionHandling 부분을 확인해 보자.

package com.jlaner.project.config.outh2;


import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        // 로그인 페이지로 리다이렉트
        response.sendRedirect("/login");
    }
}

security 로직에서 예외 발생 시 login 페이지로 리다이렉트 하게 했다.

마지막으로 보안 설정에서 PasswordEncoder부분이 필요하기 때문에 구성해 두었다.

이로써 securityconfig 부분은 확인했으니 핸들러와 쿠키 부분을 확인한 후 필터 부분을 확인해 보자.

우선 쿠키 생성 부분이다.

package com.jlaner.project.config.outh2;

import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.web.util.WebUtils;

/**
 * 이 클래스는 쿠키를 사용하여 OAuth2 인증 요청을 저장하고 로드하는 역할을 한다.
 * AuthorizationRequestRepository 인터페이스를 구현하여 OAuth2AuthorizationRequest 객체를 관리한다.
 */
@Slf4j
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    // OAuth2 인증 요청을 저장할 쿠키의 이름
    public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    // 쿠키의 만료 시간 (초 단위) 리프레시 토큰에 맞춘 14일로 저장.
    private final static int COOKIE_EXPIRE_SECONDS = 1209600;

    /**
     * 쿠키에서 OAuth2 인증 요청을 로드하고 이를 제거한다.
     *
     * @param request  HttpServletRequest 객체
     * @param response HttpServletResponse 객체
     * @return OAuth2AuthorizationRequest 객체 (존재하는 경우), 그렇지 않으면 null
     */
    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
        return this.loadAuthorizationRequest(request);
    }

    /**
     * 쿠키에서 OAuth2 인증 요청을 로드한다.
     *
     * @param request HttpServletRequest 객체
     * @return OAuth2AuthorizationRequest 객체 (존재하는 경우), 그렇지 않으면 null
     */
    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
        return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class);
    }

    /**
     * OAuth2 인증 요청을 쿠키에 저장한다.
     *
     * @param authorizationRequest 저장할 OAuth2AuthorizationRequest 객체
     * @param request              HttpServletRequest 객체
     * @param response             HttpServletResponse 객체
     */
    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {

        // authorizationRequest가 null이면 쿠키를 제거한다.
        if (authorizationRequest == null) {
            removeAuthorizationRequestCookies(request, response);
            return;
        }
        // OAuth2 인증 요청을 쿠키에 추가한다.
        CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
    }

    /**
     * OAuth2 인증 요청 쿠키를 제거한다.
     *
     * @param request  HttpServletRequest 객체
     * @param response HttpServletResponse 객체
     */
    public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
        CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
    }

}

 

이전에도 설명했듯 쿠키를 사용하고 쿠키에는 인증 요청을 저장하고 사용한다. 

oauth2 인증 과정에서 상태 정보를 유지하기 위해 쿠키를 사용한다. 인증 후 보안상 문제로 인증 정보를 정리해주어야 하는데 인증을 진행하고 로드하고 제거하는 부분을 담당한다. 다음 핸들러 부분에서 인증 요청 쿠키를 제거하는데 이 부분에서 문득 제거는 핸들러에서 사용하지만 다른 부분은 사용하지 않는데 어떻게 처리가 되나 흐름이 궁금해졌다. 먼저 핸들러 부분을 확인하고 흐름을 알아보자.

 

OAuth2SuccessHandler.class

package com.jlaner.project.config.outh2;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;

import java.io.IOException;
import java.time.Duration;
import java.util.Map;

/**
 * OAuth2 로그인 성공 시 처리하는 핸들러 클래스입니다.
 */
@RequiredArgsConstructor
@Component
@Slf4j
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    // 리프레시 토큰을 저장할 쿠키의 이름
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    // 리프레시 토큰의 유효 기간 (14일)
    public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);
    // 액세스 토큰의 유효 기간 (1일)
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofSeconds(10);
    // 인증 성공 후 리다이렉트할 기본 경로
    public static final String REDIRECT_PATH = "/home";


    private final TokenProvider tokenProvider;
    private final RefreshTokenRedisService refreshTokenRedisService;
    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
    private final MemberService memberService;

    /**
     * 인증 성공 시 호출되는 메서드입니다.
     *
     * @param request  HttpServletRequest 객체
     * @param response HttpServletResponse 객체
     * @param authentication 인증 정보
     * @throws IOException 입출력 예외 발생 시
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = extractEmail(oAuth2User.getAttributes());
        Member member = memberService.findByEmail(email);
        log.info("member={}", member);
        log.info("-------2--------");

        // 리프레시 토큰 생성 및 저장
        String refreshToken = tokenProvider.generateToken(member, REFRESH_TOKEN_DURATION);
        addRefreshTokenToCookie(request, response, refreshToken);

        // 액세스 토큰 생성
        String accessToken = tokenProvider.generateToken(member, ACCESS_TOKEN_DURATION);

        saveToken(member.getId(),accessToken, refreshToken);

        String targetUrl = getTargetUrl(accessToken);

        clearAuthenticationAttributes(request, response);

        try {
            // 인증 성공 후 리다이렉트
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
            log.info("Response status: {}", response.getStatus());
            log.info("Response headers: {}", response.getHeaderNames());
            log.info("Response locale: {}", response.getLocale());

        } catch (Exception e) {
            log.error("Error during redirect", e);
            throw e;
        }
    }

    /**
     * 리프레시 토큰과 액세스 토큰을 저장합니다.
     *
     * @param memberId 사용자 ID
     * @param newAccessToken 새로운 액세스 토큰
     * @param newRefreshToken 새로운 리프레시 토큰
     */
    private void saveToken(Long memberId,String newAccessToken, String newRefreshToken) {

        RefreshToken findRefreshToken = refreshTokenRedisService.findByMemberId(memberId);
        if (findRefreshToken != null) {
            findRefreshToken.refreshTokenUpdate(newRefreshToken);
        } else {
            findRefreshToken = new RefreshToken(String.valueOf(memberId),memberId,newAccessToken, newRefreshToken);
        }
        refreshTokenRedisService.saveToken(findRefreshToken);
    }

    /**
     * 리프레시 토큰을 쿠키에 추가합니다.
     *
     * @param request HttpServletRequest 객체
     * @param response HttpServletResponse 객체
     * @param refreshToken 리프레시 토큰
     */
    private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) {
        int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds();

        // 기존 쿠키 삭제 후 새로운 쿠키 추가
        CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME);
        CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge);
        log.info("Refresh token added to cookie with max age: {}", cookieMaxAge);
    }

    /**
     * 인증 관련 속성을 정리합니다.
     *
     * @param request HttpServletRequest 객체
     * @param response HttpServletResponse 객체
     */
    private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
    }
    private String getTargetUrl(String token) {
        return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
                .queryParam("token", token)
                .build()
                .toUriString();
    }

    private String extractEmail(Map<String, Object> attributes) {
        //카카오 이메일  추출
        if (attributes.containsKey("kakao_account")) {
            Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
            if (kakaoAccount.containsKey("email")) {
                return (String) kakaoAccount.get("email");
            }
        }

        // 구글과 같은 다른 서비스의 이메일 추출
        if (attributes.containsKey("email")) {
            return (String) attributes.get("email");
        }

        // 네이버의 이메일 추출
        if (attributes.containsKey("response")) {
            Map<String, Object> response = (Map<String, Object>) attributes.get("response");
            if (response.containsKey("email")) {
                return (String) response.get("email");
            }
        }
        throw new IllegalArgumentException("Cannot extract email from OAuth2 attributes");
    }
}

 

각 주석으로 어떤 일을 하는지에 대해서는 기술했다.

우선 특별한 부분으로는 이전 Oauth2를 사용한 포스팅과 달리 Email을 추출하는 로직이 있다. 이 부분은 각 서비스마다 반환해 주는 형식이 다르기 때문에 attribute의 각 키에서 꺼내와서 바인딩해 주었다.

다른 로직을 먼저 설명하고 아까 알아보기로 한 흐름을 알아보도록 하겠다.

 

주석으로 어느 정도는 설명이 되겠지만 리프레시 토큰과 액세스 토큰을 저장하는 로직과 리프레시 토큰을 쿠키에 추가하는 이유 등에 대해 알아보고 넘어가도록 하겠다.

우선 리프레시 토큰과 액세스 토큰은 레디스에 저장을 한다. 이는 이전에 설명했듯 쿠키에도 리프레시 토큰을 저장하고 사용하니 보안상 추가적으로 HTTPS나 HttpOnly 등으로 보안을 추가할 수 있지만 레디스를 사용해 중앙화된 방식으로 토큰을 관리하기 용이하기 때문이다.

물론 추가적인 리소스 비용이 증가하겠지만 단점보단 이점이 더 크다 생각했다.

레디스로 토큰을 저장하는 service로직은 이전에 확인했으니까 사용 이유에 대해서만 알아보았고 다음은 리프레시 토큰을 쿠키에 추가하는 이유를 알아보자.

알아보기 앞서 필자가 구성한 CookieUtil을 확인하고 넘어가자.

package com.jlaner.project.util;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.SerializationUtils;

import java.util.Base64;
import java.util.Optional;

public class CookieUtil {
    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true); // HttpOnly 속성 설정
        cookie.setMaxAge(maxAge);

        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies == null) {
            return;
        }

        for (Cookie cookie : cookies) {
            if (name.equals(cookie.getName())) {
                cookie.setValue("");
                cookie.setPath("/");
                cookie.setHttpOnly(true); // HttpOnly 속성 설정
                cookie.setMaxAge(0);
                response.addCookie(cookie);
            }
        }
    }

    public static String serialize(Object obj) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(obj));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> cls) {
        return cls.cast(
                SerializationUtils.deserialize(
                        Base64.getUrlDecoder().decode(cookie.getValue())
                )
        );
    }
    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    return Optional.of(cookie);
                }
            }
        }
        return Optional.empty();
    }
}

 

보안상 문제로 HttpOnly를 추가했고 SSL 인증서를 발급받아 securityconfig에 추가적으로 아래와 같이 HTTPS로 둘 수 있다.

           .requiresChannel(channel -> channel
                .anyRequest().requiresSecure()
            )

Self-signed 인증서 생성으로 설정하고 포트를 443을 사용하도록 8443으로 구동하게 할 수 있지만 실제 운영에서는 공인된 인증서를 사용하는 것이 좋고 이 설정에 대해선 더 공부를 해서 구성하도록 하고 보안을 추가하기 위해 다른 추가적인 기술을 더했다.

쿠키에 리프레시 토큰을 생성하는 이유로는 지속적인 인증을 유지할 수 있고 사용자 경험을 개선할 수 있기 때문이다.

이는 filter를 구현한 부분에서 이중으로 검증할 것이다. 변조되지 않았는지 유효한지 검증할 것이다. 이는 filter의 설명 부분에서 더 자세하게 다룰 것이다.

 

다음으로는 인증 요청 쿠키를 사용하는 것에 대한 흐름을 알아보고 필터 부분으로 넘어가자.

흐름은 OAuth2 인증 요청을 시작하면 OAuth2AuthorizationRequest 객체가 생성되어 쿠키에 저장되고 이 쿠키는 인증 과정 중 상태 정보를 유지하기 위해 사용된다. 사용자가 인증 제공자인 카카오나 구글, 네이버에서 인증을 완료하고 인증 제공자는 사용자와 관련된 정보를 포함해 애플리케이션의 리다이렉션 URI로 리다이렉션 한다. 그 후 애플리케이션에서 인증 성공 처리를 해서 위 핸들러가 onAuthenticationSuccess에 인증 성공 처리를 한다. 인증 성공을 하면 성공한 URI로 이동하게 되고 인증 성공이기 때문에 상태 정보를 더 유지할 필요가 없으므로 쿠키를 제거하는 것이다. 이 쿠키를 제거함으로 불필요한 메모리 사용을 줄이고 인증이 성공되고 나서는 토큰을 사용할 것이기 때문이다. 이 흐름으로 안전한 인증이 가능하다.

 

다음은 필터 로직을 확인하자.

 

TokenAuthenticationFilter.class

package com.jlaner.project.config.jwt;


import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.exception.CustomUnauthorizedException;
import com.jlaner.project.repository.MemberRepository;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.Duration;


@RequiredArgsConstructor
@Slf4j
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final RefreshTokenRedisService refreshTokenRedisService;
    private final MemberRepository memberRepository;
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";
    private int count =0;
    /**
     * 요청을 필터링하여 JWT 토큰을 검증하고 인증 정보를 설정하는 메서드
     *
     * @param request     클라이언트에서 온 HTTP 요청
     * @param response    서버로부터 클라이언트로 응답
     * @param filterChain 다음 필터로 진행할 수 있도록 체인을 제공하는 객체
     * @throws ServletException 필터 처리 중 발생할 수 있는 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String requestURI = request.getRequestURI();

        // 로그인 페이지와 정적 리소스에 대해서는 필터를 무시
        if (requestURI.startsWith("/css") || requestURI.startsWith("/pngs")) {
            filterChain.doFilter(request, response);
            return;
        }

        count++;
        log.info("count={}", count);
        log.info("request={}", request);


        // HTTP 요청 헤더에서 Authorization 헤더 값을 가져옴
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        log.info("authorizationHeader ={}", authorizationHeader);
        // Authorization 헤더 값에서 토큰을 추출
        String token = getAccessToken(authorizationHeader);
        log.info("token = {}", token);
        log.info("-------4--------");


        //AccessToken이 만료되었는지 확인
        if (token != null && !tokenProvider.validToken(token)) {
            log.info("Access Token 만료");
            //만료라면 쿠키에서 리프레시 토큰 값을 가져온다.
            String refreshTokenValue = CookieUtil.getCookie(request, REFRESH_TOKEN_COOKIE_NAME)
                    .map(Cookie::getValue)
                    .orElse(null);
            log.info("refreshToken={}", refreshTokenValue);

            //리프레시 토큰 값이 null이 아니라면 쿠키의 refreshToken과 레디스의 refreshToken이 같은지 추가 검증
            if (refreshTokenValue != null) {
                //쿠키의 리프레시토큰 값이 레디스에 있다면 검증된 값이기에 진행
                RefreshToken refreshToken = refreshTokenRedisService.findByRefreshToken(refreshTokenValue);

                //반환되는 리프레시 토큰 값이 존재하고 valid로 만료를 검증해서 검증시 액세스 토큰을 발급
                if (refreshToken != null && tokenProvider.validToken(refreshTokenValue)) {
                    Member member = memberRepository.findById(refreshToken.getMemberId())
                            .orElse(null);

                    if (member != null) {
                        String newAccessToken = tokenProvider.generateToken(member, ACCESS_TOKEN_DURATION);
                        //새로 발급한 액세스 토큰을 업데이트 하고 레디스에 저장.
                        refreshToken.accessTokenUpdate(newAccessToken);
                        refreshTokenRedisService.saveToken(refreshToken);

                        log.info("새로운 액세스 토큰 발급={}", newAccessToken);

                        response.setHeader(HEADER_AUTHORIZATION, TOKEN_PREFIX + newAccessToken);
                        // 새로운 액세스 토큰으로 인증 객체 생성 및 설정
                        Authentication authentication = tokenProvider.getAuthentication(newAccessToken);
                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    } else {
                        log.info("Not Found Member");
                        response.sendRedirect("/login?error=memberNotFound");
                    }
                    } else {
                        //토큰이 null이거나 만료라면 다시 로그인
                        //401 오류 페이지를 로그인 페이지로 이동하게끔 구성
                        log.info("Invalid Refresh Token");
                        response.sendRedirect("/login?error=invalidRefreshToken");
                    }
                } else {
                    //쿠키의 리프레시 토큰이 유효하지 않은 경우 (만료 또는 잘못된 토큰)
                    log.info("Invalid Refresh Token");
                    response.sendRedirect("/login?error=missingRefreshToken");
                }
            }


        // 추출된 토큰이 유효한 경우
        if (tokenProvider.validToken(token)) {
            log.info("인증 성공");
            // 토큰을 사용하여 인증 객체를 가져옴
            Authentication authentication = tokenProvider.getAuthentication(token);
            // SecurityContext에 인증 객체를 설정하여 인증 상태를 유지
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 다음 필터로 요청과 응답을 전달
        filterChain.doFilter(request, response);
}

    /**
     * Authorization 헤더에서 JWT 토큰을 추출하는 메서드
     *
     * @param authorizationHeader Authorization 헤더 값
     * @return 추출된 JWT 토큰
     */
    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

 

각 주석으로 어떤 인증 과정을 거치는지 설명했다.

이를 통해서 토큰 검증을 하고 인증을 거쳐 재발급하거나 로그인 페이지로 이동하게끔 구성했다.

이 로직은 필자의 생각대로 구현한 로직이다.

위 필터는 필자의 생각으로 로그인 이후에 요청이 오면 이 필터를 지나가며 만약 액세스 토큰이 만료되거나 변조되었다면 별도의 동작이나 엔드포인트 실행 없이 실행하게끔 구성한 것인데 액세스 토큰의 유효 기간을 일부러 아주 짧게 했고 로그인 이후 액세스 토큰이 만료까지 기다렸다 추가적인 동작을 했을 때 아래와 같은 예외가 발생했다.

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2024-08-02T09:50:31Z. Current time: 2024-08-02T09:50:35Z, a difference of 4670 milliseconds.  Allowed clock skew: 0 milliseconds.
2024-08-02T18:50:35.661+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : count=7
2024-08-02T18:50:35.661+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@761f2193
2024-08-02T18:50:35.662+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =null
2024-08-02T18:50:35.662+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : token = null
2024-08-02T18:50:35.662+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : -------4--------
2024-08-02T18:50:35.665+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [cee92cd6] String com.jlaner.project.controller.TestController.home(String,HttpServletResponse,Model)
2024-08-02T18:50:35.666+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.project.controller.TestController    : accessToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyNTkyMjAxLCJleHAiOjE3MjI1OTIyMzEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.Zk2DgIdHie8WsNagxqXlhk65Gr8A0DLtbXUhBMvXI_E
2024-08-02T18:50:35.674+09:00  INFO 85471 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [cee92cd6] String com.jlaner.project.controller.TestController.home(String,HttpServletResponse,Model) time=9ms ex=io.jsonwebtoken.ExpiredJwtException: JWT expired at 2024-08-02T09:50:31Z. Current time: 2024-08-02T09:50:35Z, a difference of 4670 milliseconds.  Allowed clock skew: 0 milliseconds.
2024-08-02T18:50:35.688+09:00 ERROR 85471 --- [project] [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: io.jsonwebtoken.ExpiredJwtException: JWT expired at 2024-08-02T09:50:31Z. Current time: 2024-08-02T09:50:35Z, a difference of 4670 milliseconds.  Allowed clock skew: 0 milliseconds.] with root cause

 

액세스 토큰이 만료되었다고 예외를 발생할 줄 몰랐다는 것이 잘못 생각한 점이다. 만료되었어도 액세스 토큰 값을 가지고 로직을 이어갈 줄 알았지만 로직이 이어가지 않고 예외를 발생했다. 이 예외를 발생하는 부분에서 의문이 든 것이 분명 필터부에는 만료된 토큰 값을 가지고 새로운 리프레시 토큰을 발급하는 로직이 있음에도 예외가 발생했다. 이 예외를 발생한 부분을 확인해 보았다. 위 로그에서 알 수 있듯이 필터에서는 토큰 값이 null이 되었다. 예외가 발생하는 곳은 controller임을 알 수 있다. ThreadLocalLogTrace는 필자가 AOP를 사용해서 MVC 패턴에 로그를 남기도록 설정한 것이다. 컨트롤러에서 예외가 발생했고 아래에 컨트롤러 로직을 확인해 보자.

package com.jlaner.project.controller;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.domain.ScheduleData;
import com.jlaner.project.service.MemberService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;

import java.security.Principal;

@Controller
@Slf4j
@RequiredArgsConstructor
public class TestController {
    private final TokenProvider tokenProvider;
    private final MemberService memberService;

    @GetMapping("")
    public String moveHome() {
        return "home";
    }

    @GetMapping("/login")
    public String login() {

        return "login/login";
    }
    @GetMapping("/home")
    public String home(@RequestParam("token") String token,
                       HttpServletResponse response,
                       Model model) {
        // accessToken을 로그에 출력하여 확인
        log.info("accessToken={}", token);

        // accessToken을 이용하여 사용자 정보를 조회
        Long memberId = tokenProvider.getMemberId(token);
        Member findMember = memberService.findByMemberId(memberId);
        response.setHeader("Authorization", "Bearer " + token);

        // 사용자 이름을 모델에 추가
        model.addAttribute("memberName", findMember.getName());
        model.addAttribute("scheduleData", new ScheduleData());
        model.addAttribute("post", new Post());

        // home 뷰 반환
        return "home";
    }
    @GetMapping("/testPage")
    @PreAuthorize("isAuthenticated()")
    public String testPage() {
        log.info("testPage");

        return "testPage";
    }


}

 

위 컨트롤러 로직과 실행한 디버그이다. 디버그는 null인 것을 확인하려고 디버깅해 본 것이다.

처음 접하고 자바스크립트로 Authorization 헤더를 만들어서 넘겨줄까 생각도 해보고 자바스크립트로 헤더도 넘겨보고 서버 측에서 헤더를 구성할 수 있도록 해보아도 요청이 들어오는 헤더에는 null만이 들어왔다.

하다 보니 뭐가 어떻게 진행되었는지 어디서부터 손 봐야 할지 어려워졌지만 차근차근해보도록 하자.

 

필자가 원래 의도했던 흐름과 예외가 발생한 부분과 이유를 먼저 확인하자

원래 필자의 흐름은 소셜 로그인을 진행한 후 클라이언트 측에서 서버 측에 요청이 올 때 필터에서 Authorization 헤더에 토큰 값을 가지고 위 필터 로직을 거쳐서 만일 액세스 토큰의 값이 만료되었거나 변조가 되었다면 필터부에서 처리할 수 있도록 구성했다.

하지만 예외를 살쳐보기 이전에 필터부에서 헤더 값을 로그로 남긴 부분을 보면 null인 것을 확인할 수 있다.

우선 이 부분을 확인하기 위해서 디버깅도 위처럼 해보았고 스크립트를 통해서 헤더 값을 생성하게도 해보았다.

document.addEventListener('DOMContentLoaded', () => {
    const token = localStorage.getItem('accessToken');

    if (token) {
        fetch('/home', {
            method: 'GET',
            headers: {
                'Authorization': `Bearer ${token}`
            }
        })
        .then(response => response.text())
        .then(data => {
            console.log('홈 페이지 데이터', data);
        })
        .catch(error => {
            console.error('Error:', error);
            window.location.href = '/login'; // 로그인 페이지로 리디렉션
        });
    } else {
        window.location.href = '/login'; // 로그인 페이지로 리디렉션
    }
});

 

하지만 그대로 null이었고 서버에서 위와 같이 오류가 나왔다.

필자의 생각으로는 이 오류는 login에서 소셜 로그인을 처리하고 home으로 리다이렉트 하는 과정에서 handler에서 먼저 인증 처리를 한 후에 정보들을 가지고 필터로직으로 이동하는 흐름으로 알고 있다. 서버의 입장에서는 login에서 home으로 이동하는 요청에서 핸들러가 인증을 하고 필터부에서 필터링하기 때문에 로그인 시 home으로 이동해서 액세스 토큰을 생성하고 난 후부터 사용할 수 있기 때문에 첫 로그인 시 헤더는 null이고 home에서 새로고침으로 다시 페이지를 로드하게 될 때는 클라이언트 측에서 헤더 값을 넘기게끔 구성해야 하지만 

이 부분에 대해서는 공부를 더 해서 차후에 해보도록 하겠다. 물론 이런 오류가 생겨서 공부를 해보았지만 시도해 본 방법들이 다 안 돼서 더 공부해서 추후에 다뤄보도록 하겠다.

이 방법이 안된다면 다른 방법으로 해봐야겠다고 생각했고 클라이언트 측에서 할 수 없을 것 같다면 이번엔 필터를 하나 더 생성해서 필터에서 헤더를 먼저 생성하면 어떨까 생각이 들었다. 구현한 로직을 확인해 보자.

 

확인하기 앞서 이 필터를 구성하는 방법은 틀린 방법이다. 구성하기 이전에 좀 더 흐름에 대해 생각했더라면 틀린 것을 알았겠지만 구현해보고 나서 틀린 것을 체감했다. 로직을 확인해 보자.

package com.jlaner.project.config.outh2;

import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.*;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;

import java.io.IOException;


@WebFilter("/*")
@Slf4j
@RequiredArgsConstructor
@Order(1)
public class AddAuthorizationFilter implements Filter {
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    private final RefreshTokenRedisService refreshTokenRedisService;
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        String refreshTokenValue = CookieUtil.getCookie(httpServletRequest, REFRESH_TOKEN_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse(null);
        log.info("refreshToken={}", refreshTokenValue);
        if (refreshTokenValue != null) {
            try {
                RefreshToken refreshToken = refreshTokenRedisService.findByRefreshToken(refreshTokenValue);
                if (refreshToken != null && refreshToken.getAccessToken() != null) {
                    httpServletResponse.setHeader("Authorization", "Bearer " + refreshToken.getAccessToken());
                    log.info("헤더를 설정합니다. {}", refreshToken.getAccessToken());
                } else {
                    log.info("유효한 access token을 찾을 수 없습니다.");
                }
            } catch (Exception e) {
                log.error("refreshToken으로부터 accessToken을 가져오는 중 오류 발생: ", e);
            }
        } else {
            log.info("Authorization을 설정하기 위해 필요한 refreshToken이 null 입니다.");
        }
        filterChain.doFilter(request, response);
    }

}
package com.jlaner.project.config;


import com.jlaner.project.config.jwt.TokenAuthenticationFilter;
import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.config.outh2.*;
import com.jlaner.project.repository.MemberRepository;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.RefreshTokenRedisService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final TokenProvider tokenProvider;
    private final MemberRepository memberRepository;
    private final MemberService memberService;
    private final CustomOauth2UserService customOauth2UserService;
    private final RefreshTokenRedisService refreshTokenRedisService;
    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    @Bean
    public WebSecurityCustomizer configure() { // 스프링 시큐리티 기능 비활성화
        return (web) -> web.ignoring()
//                .requestMatchers(toH2Console())
                .requestMatchers(
                        new AntPathRequestMatcher("/img/**"),
                        new AntPathRequestMatcher("/css/**"),
                        new AntPathRequestMatcher("/js/**")
                );
    }

    // HTTP 보안 설정을 구성하는 메서드
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
         http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement(management -> management.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                 .addFilterBefore(addAuthorizationFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .authorizeRequests(auth -> auth
                        .requestMatchers(
                                new AntPathRequestMatcher("/login/**"),
                                new AntPathRequestMatcher("/pngs/**"),
                                new AntPathRequestMatcher("/error/**"),
                                new AntPathRequestMatcher("/favicon.ico"),
                                new AntPathRequestMatcher("/h2-console/**"),
                                new AntPathRequestMatcher("/actuator/**"))
                        .permitAll() // 위 경로들은 인증 없이 접근 가능
                        .requestMatchers(
                                new AntPathRequestMatcher("/api/**")
                        )
                        .authenticated()
                        .anyRequest()
                        .permitAll()
                )
                .oauth2Login(login -> login
                        .loginPage("/login")
                        .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository()))
                        .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(customOauth2UserService))
                        .successHandler(oAuth2SuccessHandler())
                )
                 .exceptionHandling(exceptionHandling -> exceptionHandling
                         .authenticationEntryPoint(customAuthenticationEntryPoint));


         return http.build();
    }

    @Bean
    public OAuth2SuccessHandler oAuth2SuccessHandler() {
        return new OAuth2SuccessHandler(tokenProvider,
                refreshTokenRedisService,
                oAuth2AuthorizationRequestBasedOnCookieRepository(),
                memberService
        );
    }


    @Bean
    public TokenAuthenticationFilter tokenAuthenticationFilter() {
        return new TokenAuthenticationFilter(tokenProvider,refreshTokenRedisService,memberRepository);
    }
    @Bean
    public AddAuthorizationFilter addAuthorizationFilter() {
        return new AddAuthorizationFilter(refreshTokenRedisService);
    }


    @Bean
    public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
        return new OAuth2AuthorizationRequestBasedOnCookieRepository();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

새로운 필터와 필터를 추가하기 위해 기존 구성했던 TokenAuthenticationFilter 앞에 구성하기 위해 order로 순서를 정해주었다.

이 필터를 구성하며 생각했던 부분은 기존 필터가 헤더를 찾을 수 없고 클라이언트 측에서 헤더를 넘기게 만드는 것이 잘 되지 않았으니 필터 앞부분에서 헤더를 구성해 주면 뒤 필터에서 사용할 수 있지 않을까 라는 생각으로 구성해서 사용해 보았다. 결과는 역시 되지 않았다.

예외가 발생한 것도 새로운 액세스 토큰을 발급했더라면 생기지 않았을 예외라 생각되기 때문에 예외를 잡아주지는 않도록 하겠다.

INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.outh2.AddAuthorizationFilter     : 헤더를 설정합니다. eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyODczOTc4LCJleHAiOjE3MjI4NzQwMDgsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.chulTdRSveQaY9njVmx_GjGA1Y-v-UWbs5dpiljJoq4
INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /home, Method = GET
INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@37fc95c8
INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/home
INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.jwt.TokenAuthenticationFilter    : request PathInfo=null
INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =null
INFO 72910 --- [project] [nio-8080-exec-2] c.j.p.c.jwt.TokenAuthenticationFilter    : token = null

 

위 로그로 헤더가 세팅되지 않는 것을 확인할 수 있고 진행하고 나서 깨달은 점은 response에 헤더를 추가한다 하더라도 같은 하나의 요청 안에서 앞 필터에 헤더를 추가하더라도 뒤 필터에서 그 헤더를 사용할 수 없을 것이다. 이 부분을 필자가 놓친 점이다.

AddAuthorizationFilter로직에서 모든 엔드포인트에 헤더를 추가하겠다는 필터이다.

처음 login에서 home으로 이동할 때는 헤더를 생성해서 아래와 같이 확인할 수 있다. 그렇다면 의문이 들었다 왜 헤더를 생성했는데 토큰이 만료된 시점에서 새로고침을 하면 헤더를 넘길 수 없는 걸까?

구성하며 간과한 사실이 있었다 HTTP는 무상태를 유지한다는 점이다. 그 이유로 쿠키와 세션을 사용하는 것인데 너무 간과하고 있었다.

각 요청에 독립적이기 때문에 헤더가 필요하다면 요청 시마다 헤더를 부여해줘야 한다. 이전에 쿠키를 구성할 때 무상태이기 때문에 쿠키를 사용했던 것이었는데 잊어버리고 있었다.

 

생각해 낸 방법이 틀린 방법이었으니 다른 방법을 갈구해야 한다.

클라이언트 측과 서버 측에서 처리할 수 있는 방법이 2가지가 갈리고 인증은 서버에서 하기로 하고 헤더는 클라이언트 측에서 넘겨줄 수 있도록 구성해보려 한다.

우선 고쳐야 할 점들을 짚고 넘어가자.

이전에 생성한 필터로직은 사용하지 않을 것이다. AddAuthorizationFilter는 삭제하도록 하고 TokenAuthenticationFilter에서 토큰을 검사하고 발급하는 로직도 삭제하도록 하겠다.

 

로직을 새로 구성하기 이전에 예외에 대해 알아보고 넘어가자. 

위 필터로직을 구성할 때 response.send로 사용한 부분을 볼 수 있다. 이 부분은 원래 Exception으로 구성했었다.

CustomJwtException이라는 클래스를 만들어 ExceptionHandler로 잡아서 login으로 이동할 수 있게끔 구성했었는데 필터에서는 Exception이 잡히지 않는다 이 이유로는 아래 사진을 확인하면 된다.

 

filter는 요청과 응답 맨 앞에 존재하고 예외를 처리하는 부분은 HandlerExceptionResolver로 필터부에서 생긴 Exception을 처리할 수 없다. 이 문제로 기존 구성했었던 예외로 처리하는 것은 실패가 되었다. 물론 구조가 이러한 것은 알고 있었지만 예외처리가 이렇게 작동될 줄 몰랐던 착오였다.

 

예외도 알아보았으니 어떻게 진행할지 다시 계획을 세워보도록 하겠다.

일단 필터에서 토큰 만료를 검사하고 액세스 토큰이 만료일 때 리프레시 토큰을 검사해서 리프레시 토큰이 만료가 아니라면 새로운 액세스 토큰을 발급하는 방식으로 진행했다. 이 로직을 구성하는 부분을 컨트롤러로 옮겨야겠다고 생각했다. 예외가 발생한 부분도 토큰 만료일 때 액세스 토큰이 만료되었는데 만료된 토큰으로 사용자 정보를 레디스에서 찾으려 하니 발생한 예외였다. 컨트롤러에서 예외를 잡아주거나 정상적인 흐름으로 이어질 수 있도록 구성해야 했고 필터나 home에서 새로고침할 때 Authorization 헤더를 매 요청마다 생성하는 것은 실패했기 때문에 컨트롤러에서 처리를 해보도록 하겠다.

필자의 프로젝트는 SPA(Single Page Application)으로 하나의 페이지에서 스크립트를 사용해 동작할 것이기 때문에 로그인 이후 home으로 이동해 home에서 api로 통신해 진행할 것이다.

이 프로젝트의 구조에 맞춰 진행해 보도록 하겠다.

package com.jlaner.project.controller;

import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.Post;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.domain.ScheduleData;
import com.jlaner.project.service.MemberService;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.util.UriComponentsBuilder;

import java.io.IOException;
import java.security.Principal;
import java.time.Duration;

@Controller
@Slf4j
@RequiredArgsConstructor
public class TestController {
    private final TokenProvider tokenProvider;
    private final MemberService memberService;
    private final RefreshTokenRedisService refreshTokenRedisService;

    @GetMapping("")
    public String movelogin(){
        return "login/login";
    }

    @GetMapping("/login")
    public String login() {

        return "login/login";
    }
    @GetMapping("/home")
    public String home(@RequestParam("token") String token,
                       HttpServletRequest request,
                       HttpServletResponse response,
                       Model model) {
        // accessToken을 로그에 출력하여 확인
        log.info("accessToken={}", token);

        try {
            Long memberId;
            String newAccessToken = null;

            if (tokenProvider.isTokenExpired(token)) {
                log.info("토큰이 만료되었습니다. 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.");
                String refreshTokenValue = CookieUtil.getCookie(request, "refresh_token")
                        .map(Cookie::getValue)
                        .orElse(null);

                if (refreshTokenValue != null) {
                    RefreshToken refreshToken = refreshTokenRedisService.findByRefreshToken(refreshTokenValue);
                    if (refreshToken != null) {
                        memberId = refreshToken.getMemberId();
                        Member findByMember = memberService.findByMemberId(memberId);

                        newAccessToken = tokenProvider.generateToken(findByMember, Duration.ofDays(1));
                        //새로운 액세스 토큰을 발급해야 하기 때문에 redis에 저장된 토큰 정보의 AccessToken를 업데이트
                        RefreshToken updateToken = refreshToken.accessTokenUpdate(newAccessToken);
                        refreshTokenRedisService.saveToken(updateToken);

                        response.setHeader("Authorization", "Bearer " + newAccessToken);
                        log.info("새로운 액세스 토큰 발급={}", newAccessToken);

                        // 새로운 토큰을 포함하여 리다이렉트
                        String targetUrl = UriComponentsBuilder.fromUriString("/home")
                                .queryParam("token", newAccessToken)
                                .build()
                                .toUriString();
                        return "redirect:" + targetUrl;
                    } else {
                        log.error("유효하지 않은 리프레시 토큰입니다.");
                        return "redirect:/login?error=invalidRefreshToken";
                    }
                } else {
                    log.error("리프레시 토큰이 존재하지 않습니다.");
                    return "redirect:/login?error=missingRefreshToken";
                }
            } else {
                memberId = tokenProvider.getMemberId(token);
            }

            Member findMember = memberService.findByMemberId(memberId);

            model.addAttribute("memberName", findMember.getName());
            model.addAttribute("scheduleData", new ScheduleData());
            model.addAttribute("post", new Post());
        } catch (Exception e) {
            log.error("오류 발생: {}", e.getMessage());
            return "redirect:/login?error=unauthorized";
        }
        log.info("home 이동");

        return "home";
    }

    @GetMapping("/testPage")
    @PreAuthorize("isAuthenticated()")
    public String testPage() {
        log.info("testPage");

        return "testPage";
    }

}

 

바꿔준 controller 로직이다. 첫 로그인과 home에서 새로고침을 했을 때를 고려해서 구성한 것이다.

다음은 필터 부분을 확인해 보자.

package com.jlaner.project.config.jwt;


import com.jlaner.project.domain.Member;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.exception.CustomUnauthorizedException;
import com.jlaner.project.repository.MemberRepository;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.Duration;


@RequiredArgsConstructor
@Slf4j
@Order(2)
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    private final TokenProvider tokenProvider;
    private final RefreshTokenRedisService refreshTokenRedisService;
    private final MemberRepository memberRepository;
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";
    private int count =0;
    /**
     * 요청을 필터링하여 JWT 토큰을 검증하고 인증 정보를 설정하는 메서드
     *
     * @param request     클라이언트에서 온 HTTP 요청
     * @param response    서버로부터 클라이언트로 응답
     * @param filterChain 다음 필터로 진행할 수 있도록 체인을 제공하는 객체
     * @throws ServletException 필터 처리 중 발생할 수 있는 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        String requestURI = request.getRequestURI();

        //정적 리소스에 대해서는 필터를 무시
        if (requestURI.startsWith("/css") || requestURI.startsWith("/pngs") || requestURI.startsWith("/js")) {
            filterChain.doFilter(request, response);
            return;
        }
        log.info("Incoming request: URI = {}, Method = {}", request.getRequestURI(), request.getMethod());
        log.info("request={}", request);
        log.info("requestURI={}", request.getRequestURI());


        // HTTP 요청 헤더에서 Authorization 헤더 값을 가져옴
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);
        log.info("authorizationHeader ={}", authorizationHeader);
        // Authorization 헤더 값에서 토큰을 추출
        String token = getAccessToken(authorizationHeader);
        log.info("token = {}", token);

        // 추출된 토큰이 유효한 경우
        if (tokenProvider.validToken(token)) {
            log.info("인증 성공");
            // 토큰을 사용하여 인증 객체를 가져옴
            Authentication authentication = tokenProvider.getAuthentication(token);
            // SecurityContext에 인증 객체를 설정하여 인증 상태를 유지
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        // 다음 필터로 요청과 응답을 전달
        filterChain.doFilter(request, response);
}

    /**
     * Authorization 헤더에서 JWT 토큰을 추출하는 메서드
     *
     * @param authorizationHeader Authorization 헤더 값
     * @return 추출된 JWT 토큰
     */
    private String getAccessToken(String authorizationHeader) {
        if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

 

로그가 좀 많지만 실행 중 필자가 계속 확인하고 싶기 때문에 구성했으니 무시해도 좋다.

다른 로직은 이전과 같기 때문에 확인하지 않아도 좋다.

INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /home, Method = GET
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@4aa1c617
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/home
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =null
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.c.jwt.TokenAuthenticationFilter    : token = null
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [1af822ed] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model)
INFO 12657 --- [project] [nio-8080-exec-4] c.j.project.controller.TestController    : accessToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTU5NDE2LCJleHAiOjE3MjI5NTk0NDYsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ._MgdGrHJKMCm96lOpVfTO5rTYcxT6Wnp0VLgYVDu3dE
INFO 12657 --- [project] [nio-8080-exec-4] c.j.project.config.jwt.TokenProvider     : tokenProvider
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [1af822ed] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [1af822ed] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=5ms
INFO 12657 --- [project] [nio-8080-exec-4] c.j.project.controller.TestController    : home 이동
INFO 12657 --- [project] [nio-8080-exec-4] c.j.p.logTrace.ThreadLocalLogTrace       : [1af822ed] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model) time=20ms

 

2024-08-07T00:52:40.981+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /home, Method = GET
2024-08-07T00:52:40.982+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@5e5016da
2024-08-07T00:52:40.982+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/home
2024-08-07T00:52:40.982+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =null
2024-08-07T00:52:40.982+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.c.jwt.TokenAuthenticationFilter    : token = null
2024-08-07T00:52:41.010+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model)
2024-08-07T00:52:41.010+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.project.controller.TestController    : accessToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTU5NDE2LCJleHAiOjE3MjI5NTk0NDYsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ._MgdGrHJKMCm96lOpVfTO5rTYcxT6Wnp0VLgYVDu3dE
2024-08-07T00:52:41.017+09:00 ERROR 12657 --- [project] [nio-8080-exec-5] c.j.project.config.jwt.TokenProvider     : 토큰이 만료되었습니다. JWT expired at 2024-08-06T15:50:46Z. Current time: 2024-08-06T15:52:41Z, a difference of 115014 milliseconds.  Allowed clock skew: 0 milliseconds.
2024-08-07T00:52:41.018+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.project.controller.TestController    : 토큰이 만료되었습니다. 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급합니다.
2024-08-07T00:52:41.022+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] |-->RefreshToken com.jlaner.project.service.RefreshTokenRedisService.findByRefreshToken(String)
2024-08-07T00:52:41.028+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] |<--RefreshToken com.jlaner.project.service.RefreshTokenRedisService.findByRefreshToken(String) time=7ms
2024-08-07T00:52:41.030+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-07T00:52:41.034+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=4ms
2024-08-07T00:52:41.039+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] |-->void com.jlaner.project.service.RefreshTokenRedisService.saveToken(RefreshToken)
2024-08-07T00:52:41.043+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] |<--void com.jlaner.project.service.RefreshTokenRedisService.saveToken(RefreshToken) time=4ms
2024-08-07T00:52:41.043+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.project.controller.TestController    : 새로운 액세스 토큰 발급=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTU5NTYxLCJleHAiOjE3MjMwNDU5NjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.1KJ5WqUn-Ueps6wCHkECXfwLwFrp-msvT5TnaTOLeYk
2024-08-07T00:52:41.044+09:00  INFO 12657 --- [project] [nio-8080-exec-5] c.j.p.logTrace.ThreadLocalLogTrace       : [d0b4711d] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model) time=34ms
2024-08-07T00:52:41.072+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /home, Method = GET
2024-08-07T00:52:41.072+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@7c746286
2024-08-07T00:52:41.072+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/home
2024-08-07T00:52:41.072+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =null
2024-08-07T00:52:41.072+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.c.jwt.TokenAuthenticationFilter    : token = null
2024-08-07T00:52:41.074+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [201d36f7] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model)
2024-08-07T00:52:41.075+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.project.controller.TestController    : accessToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTU5NTYxLCJleHAiOjE3MjMwNDU5NjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.1KJ5WqUn-Ueps6wCHkECXfwLwFrp-msvT5TnaTOLeYk
2024-08-07T00:52:41.077+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.project.config.jwt.TokenProvider     : tokenProvider
2024-08-07T00:52:41.078+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [201d36f7] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-07T00:52:41.083+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [201d36f7] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=5ms
2024-08-07T00:52:41.084+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.project.controller.TestController    : home 이동
2024-08-07T00:52:41.084+09:00  INFO 12657 --- [project] [io-8080-exec-10] c.j.p.logTrace.ThreadLocalLogTrace       : [201d36f7] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model) time=10ms

 

home으로 이동하는 부분에 있어서는 헤더가 null이지만 컨트롤러 로직으로 대체했으니 무관하다.

그럼에도 필터가 필요한 이유는 RestApi를 통한 요청으로 구성할 것이기 때문에 필요하다.

필터가 아닌 컨트롤러에서 토큰에 대한 처리할 수 있도록 만들었고 

위에서 확인했듯이 토큰에 대해서 필터에서 Exception을 처리하기 어려우니 login으로 이동하게 해서 파라미터에 error 파라미터를 넘기는 방법을 택했으며 이를 사용하기 위해 스크립트를 구성했다.

function alertError() {
            const params = new URLSearchParams(window.location.search);
            const error = params.get('error');

            if (error) {
                let message = '';

                switch (error) {
                    case 'memberNotFound':
                        message = '회원 정보를 찾을 수 없습니다. 다시 로그인해주세요.';
                        break;
                    case 'invalidRefreshToken':
                        message = '유효하지 않은 리프레시 토큰입니다. 다시 로그인해주세요.';
                        break;
                    case 'missingRefreshToken':
                        message = '리프레시 토큰이 없습니다. 다시 로그인해주세요.';
                        break;
                    case 'unauthorized':
                        message = '인증되지 않은 요청입니다. 다시 로그인해주세요.';
                        break;
                }

                alert(message);
            }
}

// 페이지가 로드될 때 showAlertFromQuery 함수를 호출
window.onload = alertError;

 

이로써 오류는 해결되었고 마지막으로 api를 구성하기로 했으니 스크립트로 Authorization 헤더를 구성하게 하고 구성한 헤더 값을 포함해 비동기 api 요청을 해서 필터 로직이 수행되고 인증도 진행이 되는지 확인해보도록 하겠다.

우선 엔드포인트 컨트롤러 로직을 구성해 주었다.

package com.jlaner.project.controller;


import com.jlaner.project.config.jwt.TokenProvider;
import com.jlaner.project.config.outh2.OAuth2SuccessHandler;
import com.jlaner.project.domain.RefreshToken;
import com.jlaner.project.service.RefreshTokenRedisService;
import com.jlaner.project.util.CookieUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.Collections;


@RequiredArgsConstructor
@RestController
@Slf4j
public class TokenApiController {

    private final RefreshTokenRedisService refreshTokenRedisService;
    private final TokenProvider tokenProvider;

    @GetMapping("/api/auth")
    @PreAuthorize("isAuthenticated()")
    public String getAuthenticated() {
        return "Authenticated";
    }

    @PostMapping("/logout")
    public void logout(HttpServletRequest request, HttpServletResponse response) {
        // 쿠키에서 리프레시 토큰 추출
        String refreshToken = CookieUtil.getCookie(request, OAuth2SuccessHandler.REFRESH_TOKEN_COOKIE_NAME)
                .map(Cookie::getValue)
                .orElse(null);

        if (refreshToken != null) {
            // Redis에서 리프레시 토큰 삭제
            RefreshToken token = refreshTokenRedisService.findByRefreshToken(refreshToken);
            if (token != null) {
                refreshTokenRedisService.deleteByMemberId(token.getMemberId());
            }
        }

        // 쿠키 삭제
        CookieUtil.deleteCookie(request, response, OAuth2SuccessHandler.REFRESH_TOKEN_COOKIE_NAME);
    }

    @GetMapping("/api/check-auth")
    public ResponseEntity<?> tokenCheck(@RequestHeader("Authorization") String token) {
        try {
            if (token != null && token.startsWith("Bearer ")) {
                String jwtToken = token.substring(7);
                Claims claims = tokenProvider.getClaims(jwtToken);
                String memberName = claims.getSubject();

                // 인증된 사용자 정보 반환
                return ResponseEntity.ok().body(Collections.singletonMap("memberName", memberName));
            } else {
                return ResponseEntity.status(401).body("Unauthorized");
            }
        } catch (Exception e) {
            return ResponseEntity.status(401).body("Invalid token");
        }
    }

}

 

로그아웃 기능과 테스트를 위한 컨트롤러 로직이다.

tokenCheck 메서드만 확인해 보도록 하겠다. 헤더의 토큰 값을 받아와 토큰이 null이 아닌지 검사하고 Bearer 로 시작하는지 검사한 후 토큰 값만 꺼내서 사용하도록 했다. 토큰에서 claims를 꺼내와 그 안에 저장된 데이터를 반환하게 했다.

새로고침으로 테스트해볼 것이고 home에 자바스크립트를 구성해 추가할 것이기 때문에 이렇게 구성한 것이다.

실제 데이터를 가져오는 api를 구성하기 위해서는 토큰 검증도 해야 하고 home 컨트롤러 구성한 것처럼 구성해야 하지만 테스트 용이기에 간단하게 구성한 것으로 이후부터는 토큰 검증하고 재발급하는 부분도 메서드로 빼서 api를 구성하도록 할 것이다.

지금은 자바스크립트로 비동기 요청에 헤더를 담아 api를 실행하게 하고 올바르게 실행되는 것을 확인하기 위함이다.

서버 측 api를 구성했으니 자바스크립트를 구성해 보자.

이전에 jlaner.js라는 자바스크립트가 있을 텐데 스크립트 맨 아래에 구성했다.

function switchTab(tab) {
    // 모든 컨텐츠 탭 숨기기
    const contentTabs = document.querySelectorAll('.content-tab');
    contentTabs.forEach(tab => tab.classList.remove('active'));

    // 선택된 탭 표시
    document.getElementById(`${tab}-tab`).classList.add('active');
}

const sharedTextarea = document.getElementById('shared-textarea');
const youtubeContainer = document.getElementById('youtube-container');
const youtubePreview = document.getElementById('youtube-preview');

function insertYouTubeVideo() {
    const url = document.getElementById('youtube-url').value;

    // URL에서 비디오 ID 추출
    let videoId = url.split('v=')[1];
    const ampersandPosition = videoId ? videoId.indexOf('&') : -1;
    if (ampersandPosition !== -1) {
        videoId = videoId.substring(0, ampersandPosition);
    }

    // YouTube 미리보기 iframe 업데이트
    youtubePreview.src = `https://www.youtube.com/embed/${videoId}`;

    // YouTube 미리보기 표시
    youtubeContainer.style.display = 'block';
}

function toggleYouTubeContainer() {
    if (youtubeContainer.style.display === 'none') {
        youtubeContainer.style.display = 'block';
        document.getElementById('youtube-link-input').style.marginTop = '0px'; // 텍스트 영역에 대한 여백 조정
    } else {
        youtubeContainer.style.display = 'none';
        document.getElementById('youtube-link-input').style.marginTop = '0'; // 텍스트 영역에 대한 여백 초기화
    }
}

function confirmDate() {
        const selectedDate = document.getElementById('schedule-date').value;
        document.getElementById('post-date').value = selectedDate;
        document.getElementById('schedule-date-hidden').value = selectedDate;
        alert("날짜가 설정되었습니다: " + selectedDate);
}


document.addEventListener('DOMContentLoaded', async () => {
    try {
        // 인증 상태를 확인하기 위해 '/api/check-auth' 엔드포인트로 요청을 보냄
        const response = await fetchWithAuth('/api/check-auth');

        // 응답이 성공적이지 않은 경우, 예: 인증 실패
        if (!response.ok) {
            // 에러 처리, 예: 로그인 페이지로 리디렉션
            window.location.href = '/login';
        } else {
            // 응답을 JSON으로 변환
            const data = await response.json();
            document.getElementById('member-name').innerText = data.memberName + '님 입니다.';
        }
    } catch (error) {
        // 요청 중 오류가 발생한 경우 콘솔에 에러를 출력하고 로그인 페이지로 리디렉션
        console.error('인증 상태 확인 중 오류 발생:', error);
        window.location.href = '/login';
    }
});

 

html이나 js코드들 전부 api 구성하면서 손을 봐야 하지만 지금은 지금 정도만 유지하는 것으로 하자.

스크립트는 비동기로 페이지가 DOM을 통해 로드될 때 비동기로 실행되도록 구성했다. 각 주석으로 설명했으니 크게 어려움은 없을 것 같으며 실제 api 구성시에는 로그인페이지로 이동하게 될 때 오류 파라미터를 꼭 넣어주도록 해서 오류처리를 하도록 하겠다.

구성은 끝났으니 실행을 해서 확인해 보도록 하자. 기존 home.html을 보면 사용자 이름을 보이게 하는 부분이 ***님 이런 식으로 되어 있지만 위 js를 보면 님입니다.로 되어있다. 즉 이 자바스크립트가 잘 실행될 시 님입니다. 를 확인하면 되는 것이다.

 

 

2024-08-07T02:28:22.216+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /home, Method = GET
2024-08-07T02:28:22.217+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@1d2f18bc
2024-08-07T02:28:22.218+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/home
2024-08-07T02:28:22.219+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =null
2024-08-07T02:28:22.219+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.c.jwt.TokenAuthenticationFilter    : token = null
2024-08-07T02:28:22.227+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [9d23be2e] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model)
2024-08-07T02:28:22.227+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.project.controller.TestController    : accessToken=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTY1MjkxLCJleHAiOjE3MjI5NjUzMjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.OQEMt0O-LuBy1PFqTs8We7Sc50bovQQom_x4cBFxuDk
2024-08-07T02:28:22.231+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.project.config.jwt.TokenProvider     : tokenProvider
2024-08-07T02:28:22.237+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [9d23be2e] |-->Member com.jlaner.project.service.MemberService.findByMemberId(Long)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.email,
        m1_0.login_id,
        m1_0.name,
        m1_0.provider,
        m1_0.provider_id,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-08-07T02:28:22.244+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [9d23be2e] |<--Member com.jlaner.project.service.MemberService.findByMemberId(Long) time=7ms
2024-08-07T02:28:22.246+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.project.controller.TestController    : home 이동
2024-08-07T02:28:22.247+09:00  INFO 25010 --- [project] [nio-8080-exec-9] c.j.p.logTrace.ThreadLocalLogTrace       : [9d23be2e] String com.jlaner.project.controller.TestController.home(String,HttpServletRequest,HttpServletResponse,Model) time=20ms
2024-08-07T02:28:22.275+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : Incoming request: URI = /api/check-auth, Method = GET
2024-08-07T02:28:22.277+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : request=org.springframework.security.web.header.HeaderWriterFilter$HeaderWriterRequest@153145c2
2024-08-07T02:28:22.278+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : requestURI=/api/check-auth
2024-08-07T02:28:22.278+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : authorizationHeader =Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTY1MjkxLCJleHAiOjE3MjI5NjUzMjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.OQEMt0O-LuBy1PFqTs8We7Sc50bovQQom_x4cBFxuDk
2024-08-07T02:28:22.278+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : token = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzIyOTY1MjkxLCJleHAiOjE3MjI5NjUzMjEsInN1YiI6Iu2VnOyngO2biCIsImlkIjoxfQ.OQEMt0O-LuBy1PFqTs8We7Sc50bovQQom_x4cBFxuDk
2024-08-07T02:28:22.281+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.c.jwt.TokenAuthenticationFilter    : 인증 성공
2024-08-07T02:28:22.294+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.logTrace.ThreadLocalLogTrace       : [e8a4960e] ResponseEntity com.jlaner.project.controller.TokenApiController.tokenCheck(String)
2024-08-07T02:28:22.298+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.controller.TokenApiController      : memberName=한지훈
2024-08-07T02:28:22.298+09:00  INFO 25010 --- [project] [nio-8080-exec-1] c.j.p.logTrace.ThreadLocalLogTrace       : [e8a4960e] ResponseEntity com.jlaner.project.controller.TokenApiController.tokenCheck(String) time=4ms

 

로그까지 확인해 보았다. 필터에서 인증을 성공하는 부분도 확인할 수 있다.

여기까지 로그인 후 인증을 하는 부분까지 확인해 보았다.

 

포스팅이 굉장히 길어지고 어지러워 보이는 부분도 있겠다고 생각은 들지만 며칠에 걸려 포스팅을 했다.

개인적인 사정이 있기도 했지만 오류나 잘 되지 않는 부분을 해결해 보려 여러 방법을 해보고 디버깅도 해보고 하느라 오래 걸렸다.

자바스크립트에 대한 공부도 더 필요했고 http와 스프링 mvc에 대해 기본적인 것을 알고 있다 생각했지만 막상 예상한 흐름 되지 않았을 때 어떤 부분이 문제구나 하고 딱 집어서 해결할 수 없었다. 로그를 찍어보고 디버깅해보고 흐름을 재정립하면서 오류를 찾아내서 해결하게 되었다. 무상태에 대한 부분이나 filter를 하나 더 구성한다던지 알고 있었던 부분도 하면서 헷갈리고 잊고 있던 부분도 다시 되새기면서  진행할 수 있던 점이 좋았다. 오류가 나면서 침울하기도 하고 답답했지만 오류가 나고 다시 알아보며 해결하니까 더 많은 것을 알게 되고 기억해서 다시 같은 실수나 오류를 반복하게 되지는 않을 것 같다. 이를 통해서 흐름을 정리하고 어떻게 오류에 접근해야 하는가를 알게 되었다.

여기까지 필자의 개인 프로젝트를 알아보았고 다음부터는 포스팅이 길어지게 되면 나눠서 올려보도록 하겠다.