카테고리 없음

[Spring] Session 인증과 JWT 인증을 이용한 OAuth2 방식의 로그인 구현

orieasy1 2024. 10. 8. 16:36

스프링 시큐리티

  • 막강한 인증(Authentication)과 인가(Authorization 혹은 권한 부여 기능)을 가진 프레임워크
  • 스프링 가반 어플리케이션에서의 보안을 위한 표준
  • 확장성을 고려한 프레임워크다보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있음

소셜 로그인을 사용하는 이유

직접 구현해야할 것이 매우 많음(배보다 배꼽이 커지는 경우), 아래 목록을 소셜 로그인을 제공하는 업체에 위임하여 개발자가 서비스 개발에 집중할 수 있음

직접 구현해야하는 목록
* 로그인 시 보안
* 회원가입 시 이메일 혹은 전화번호 인증
* 비밀번호 찾기
* 비밀번호 변경
* 회원정보 변경

 

OAuth란?

: 제 3의 서비스에 계정 관리를 맡기는 방식

 

OAuth 방식의 참여자들

  • 리소스 오너(Resource Owner): 인증 서버에 자신의 정보를 사용하도록 허가하는 주체
    • 서비스를 이용하는 사용자
  • 리소스 서버(Resource Server): 리소스 오너의 정보를 가지며 리소스 오너의 정보를 보호하는 주체
    • 네이버, 구글, 카카오 등
  • 인증 서버(Authorization Server): 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 어플리케이션
  • 클라이언트 어플리케이션(Client Application): 인증 서버에게 인증을 받고 리소스 오너의 리소슬르 사용하는 주체
    • 개발중인 서비스, OAuth방식을 사용하고자하는 서비스(주체)

 

클라이언트가 리소스 오너 정보를 취득할 수있는 4가지 방법

  1. 권한 부여 코드 승인 타입: OAuth 2.0에서 가장 잘 알려진 인증 방법 클라이언트가 리소스에 접근하는데 사용하며 권한에 접근할 수 있는 코드와 리소스 오너에 대한 액세스 토큰을 발급받는 방식
  2. 암시적 승인 타입: 서버가 없는 자바스크립트 웹 애플리케이션 클라이언트에서 주로 사용하는 방법 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 이외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 액세스 토큰을 제공받는 방식
  3. 리소스 소유자 암호 자격 증명 승인타입: 클라이언트의 패스워드를 이용해서 엑세스 토큰에 대한 사용자의 자격 증명을 교환하는 방식
  4. 클라이언트 자격증명 승인 타입: 클라이언트가 컨텍스트 외부에서 액세스 토큰을 얻어 특정 리소스에 접근을 요청하는 방식

 

권한 부여 코드 승인 타입이란?

: OAuth2.0에서 제일 흔히 쓰이고 잘 알려진 방식

 

어떤 순서로 인증이 일어나는가?

  1. 권한 요청
  2. 데이터 접근용 권한 부여
  3. 인증 코드 발급
  4. 액세스 토큰으로 발급
  5. 액세스 토큰으로 데이터에 접근

우리 책에 나와있는 예시 코드도, 내가 설명하고자하는 구현 방버법도 다 이 권한 부여 코드 승인 타입을 다르고 있다.

 

 

 

우리 책의 코드 살펴보기

책: 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 (이동욱 저)

OAuth2 + 세션 기반 인증을 바탕으로 로그인을 구현하고 있다.

 

우리 책의 전체적인 인증 과정 (Session 기반 인증)

  1. 사용자 로그인 요청: 사용자가 "구글로 로그인" 버튼을 클릭하면 브라우저는 OAuth2 제공자로 리다이렉트됨.
  2. OAuth2 인증: 사용자는 OAuth2 제공자(예: 구글)에서 로그인을 진행하고, 인증이 완료되면 어플리케이션으로 다시 리다이렉트됨. 이때 제공자는 인증 코드를 어플리케이션에 전달.
  3. 토큰 교환: 어플리케이션은 인증 코드를 액세스 토큰으로 교환하고, 그 토큰을 이용해 제공자로부터 사용자 정보를 가져옴
  4. 세션 생성: CustomOAuth2UserService는 사용자 정보를 처리한 후, 이를 SessionUser 객체에 저장하고, 이 객체를 HttpSession에 저장하여 세션 기반의 인증 상태를 유지
  5. 세션 관리: 어플리케이션은 HttpSession을 사용하여 여러 요청 간에 사용자의 인증 상태를 유지. 사용자가 페이지를 이동할 때마다 매번 다시 인증할 필요가 없음

SecurityConfig.java

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {

	// CustomOAuth2UserService를 주입받아 OAuth2 사용자 정보를 처리
  private final CustomOAuth2UserService customOAuth2UserService;

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			.csrf(csrf -> csrf.disable())
			.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()))
			.authorizeHttpRequests(authz -> authz
				.requestMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**", "/profile").permitAll()
				.requestMatchers("/api/v1/**").hasRole(Role.USER.name())
				.anyRequest().authenticated())
			.logout(logout -> logout
				.logoutSuccessUrl("/"))
			// OAuth2 로그인 설정: 로그인 성공 후 사용자 정보를 처리할 customOAuth2UserService 설정
			**.oauth2Login(oauth2 -> oauth2
				.userInfoEndpoint(userInfo -> userInfo
					.userService(customOAuth2UserService)));** 
		return http.build();
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
}

 

CustomOAuthUserService.java

@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
	// 사용자 정보를 저장하기 위한 UserRepository와 세션을 관리하는 HttpSession 주입
	private final UserRepository userRepository;
	private final HttpSession httpSession;

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2UserService delegate = new DefaultOAuth2UserService();
		OAuth2User oAuth2User = delegate.loadUser(userRequest);

		String registrationId = userRequest.getClientRegistration().getRegistrationId();
		String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
			.getUserInfoEndpoint().getUserNameAttributeName();

		// OAuth 제공자로부터 받은 사용자 정보를 OAuthAttributes 객체로 변환
		OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
		
		// 사용자 정보를 데이터베이스에 저장하거나 업데이트
		User user = saveOrUpdate(attributes);
		httpSession.setAttribute("user", new SessionUser(user));

		return new DefaultOAuth2User(
			Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
			attributes.getAttributes(),
			attributes.getNameAttributeKey());
	}

  // 세션에 사용자 정보를 저장 (SessionUser 객체로 저장하여 직렬화 처리)
	private User saveOrUpdate(OAuthAttributes attributes) {
		User user = userRepository.findByEmail(attributes.getEmail())
			.map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
			.orElse(attributes.toEntity());

		return userRepository.save(user);
	}
}

 

 

Session 인증

HTTP 프로코톨 자체는 Statelss 바식으로 매 요청마다 사용자 상태 정보를 전송해야하지만 세션 인증을 사용하면 서버가 사용자의 상태를 유지한다.

사용자가 로그인하면 세션을 생성하고, 이 세션을 통해 로그인 상태를 일정 시간동안 유지한다. 세션이 유지되는 동안에는 재로그인하지 않고도 계속해서 인증된 상태를 유지할 수 있다. 세션은 설정된 만료 시간에 따라 자동으로 종료되거나 사용자가 로그아웃할 때 삭제된다. 이렇게 세션이 만료된 다시 로그인하는 절차가 필요하다.

세션 데이터는 서버 측에서 유지되므로, 서버 자원을 소모한다. 특히 다수의 사용자가 동시에 접속할 때 서버의 메모리나 스토리지 사용량이 증가할 수 있다. 따라서 큰 규모의 애플리케이션에서는 서버 확장성과 부하 관리가 중요한 문제점이 될 수 있다.

 

 

 

JWT + OAuth2를 사용한 로그인 구현

이 둘이 함께 사용되면 세션 대신 JWT가 인증을 유지하는 역할을 한다고 보면된다. Access Token은 API 요청 시 사용되고, Refresh Token은 만료된 Access Token을 재발급하는 데 사용된다. Refresh Token은 보안상 쿠키에 저장되어 서버와의 지속적인 인증을 보장한다.

 

장점

  1. 서버에 상태를 저장할 필요 없이 클라이언트 측에서 토큰을 통해 이증 상태를 유지할 수 있어서버 확장성에 유리하다.
  2. Refresh Token을 통해 Access Token이 만료되었을 때 재발급이 가능하므로, 사용자는 로그아웃하지 않고도 지속적인 인증 상태를 유지할 수 있다.
  3. 분산된 환경에서도 JWT를 통해 인증 상태를 유지할 수 있어, 서버 간 세션 동기화가 필요하지 않으며 클라우드 환경에서 쉽게 확장할 수 있다.

 

JWT란?

JWT (JSON Web Token)는 JSON 형식으로 인코딩된 자격 증명을 사용하는 토큰 기반 인증 방식. 크게 헤더(Header), 페이로드(Payload), 서명(Signature)의 세 부분으로 구성됨.

보안을 위해서는 토큰의 유효기간이 짧은 것이 좋은데, 사용자의 입장에서는 불편하다. 이를 해결하기 위한 방법이 리프레시 토큰이다. 리프레시 토큰은 액세스 토큰이 만료되었을 때 새로운 액세스 토큰을 발급하기위한 토큰이다. 액세스 토큰을 짧게 설정하고 리프레시 토큰의 유효기간을 길게 설정하면 공격자가 액세스 토큰을 탈취해도 얼마지나지 않아서 사용할 수 없는 토큰이 될 것이다.

클라이언트는 JWT를 브라우저 쿠키나 로컬 스토리지에 저장하고, 이후 서버로 요청을 보낼 때마다 헤더에 JWT를 포함시켜서 보낸다.

 

 

개인적으로 준비한 코드 설명

1.OAuth2 인증 성공

  • 사용자가 OAuth2 제공자(예: 구글)에서 인증을 완료하면, OAuth2UserCustomService가 사용자 정보를 가져온다. 이 클래스는 사용자가 로그인할 때 OAuth2 제공자로부터 반환된 정보를 사용하여 새로운 사용자 등록 또는 기존 사용자 업데이트를 처리한다.
@RequiredArgsConstructor
@Service
public class OAuth2UserCustomService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User user = super.loadUser(userRequest); //요청을 바탕으로 유저 정보를 담은 객체 반환
        saveOrUpdate(user);

        return user;
    }

    //유저가 있으면 업데이트, 없으면 유저 생성
    private User saveOrUpdate(OAuth2User oAuth2User) {
        Map<String, Object> attributes = oAuth2User.getAttributes();

        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");

        User user = userRepository.findByEmail(email)
                .map(entity -> entity.update(name))
                .orElse(User.builder()
                        .email(email)
                        .nickname(name)
                        .build());

        return userRepository.save(user);
    }
}

 

2. JWT 발급

  • OAuth2SuccessHandler에서 사용자가 인증되면, TokenProvider를 이용해 JWT(Access Token 및 Refresh Token)를 생성한다.
    • Access Token: 짧은 유효 기간(예: 1일)으로 발급, 이 토큰을 통해 사용자의 인증 상태를 유지
    • Refresh Token: 더 긴 유효 기간(예: 14일)을 가지며, Access Token이 만료되었을 때 새로 발급받기 위해 사용
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    // Refresh Token 관련 상수 설정
    public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token";  // 쿠키에 저장될 Refresh Token의 이름
    public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14);  // Refresh Token 유효 기간 (14일)
    public static final Duration ACCESS_TOKEN_DURATION = Duration.ofDays(1);  // Access Token 유효 기간 (1일)
    public static final String REDIRECT_PATH = "/articles";  // 리다이렉션 경로

    private final TokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
    private final UserService userService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        // OAuth2 인증이 성공하면 사용자 정보를 가져옴
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email"));

        // Refresh Token 생성 후 저장 및 쿠키에 추가
        String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION);
        saveRefreshToken(user.getId(), refreshToken);
        addRefreshTokenToCookie(request, response, refreshToken);

        // Access Token 생성 후 리다이렉션 URL에 포함
        String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION);
        String targetUrl = getTargetUrl(accessToken);

        // 인증 관련 속성 제거
        clearAuthenticationAttributes(request, response);

        // 사용자를 지정된 URL로 리다이렉트
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    // Refresh Token을 저장 또는 업데이트
    private void saveRefreshToken(Long userId, String newRefreshToken) {
        RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId)
                .map(entity -> entity.update(newRefreshToken))  // 기존 토큰 업데이트
                .orElse(new RefreshToken(userId, newRefreshToken));  // 새로 저장

        refreshTokenRepository.save(refreshToken);  // DB에 저장
    }

    // Refresh Token을 쿠키에 저장
    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);  // 새 쿠키 추가
    }

    // OAuth2 인증 관련 속성 제거 (보안 목적)
    private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
        super.clearAuthenticationAttributes(request);  // 기본 인증 속성 제거
        authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);  // 인증 요청 쿠키 제거
    }

    // 리다이렉트될 URL 생성, Access Token을 쿼리 파라미터로 포함
    private String getTargetUrl(String token) {
        return UriComponentsBuilder.fromUriString(REDIRECT_PATH)
                .queryParam("token", token)
                .build()
                .toUriString();
    }
}
@RequiredArgsConstructor
@Service
public class TokenProvider {

    private final JwtProperties jwtProperties;

    // 사용자의 정보를 바탕으로 JWT 토큰을 생성
    public String generateToken(User user, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
    }

    // JWT 토큰을 생성하는 메서드 (만료일과 사용자 정보를 사용)
    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // JWT 헤더 설정
                .setIssuer(jwtProperties.getIssuer()) // 발급자 설정
                .setIssuedAt(now) // 토큰 발급 시간
                .setExpiration(expiry) // 만료 시간
                .setSubject(user.getEmail()) // 토큰의 주체 설정 (사용자 이메일)
                .claim("id", user.getId()) // 사용자 ID를 클레임으로 추가
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // 서명 알고리즘 및 비밀키 설정
                .compact(); // JWT 토큰 생성
    }

    // 주어진 토큰의 유효성을 검증
    public boolean validToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey()) // 비밀키로 서명 검증
                    .parseClaimsJws(token); // 토큰을 파싱하여 검증
            return true;
        } catch (Exception e) {
            return false; // 유효하지 않은 토큰일 경우 false 반환
        }
    }

    // 토큰에서 인증 정보 추출
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token); // 토큰에서 클레임을 추출
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        // 인증 객체를 생성하여 반환
        return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject
                (), "", authorities), token, authorities);
    }

    // 토큰에서 사용자 ID 추출
    public Long getUserId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    // 토큰에서 클레임(Claim) 정보를 가져오는 메서드
    private Claims getClaims(String token) {
        return Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey()) // 서명 검증을 위한 비밀키 설정
                .parseClaimsJws(token) // 토큰을 파싱하여 클레임을 가져옴
                .getBody(); // 클레임 본문 반환
    }
}

 

3. 쿠키에 Refresh Token 저장

  • OAuth2SuccessHandler에서 쿠키에 Refresh Token을 저장. 이를 통해 클라이언트는 서버와의 세션을 지속할 수 있다. 클라이언트는 쿠키를 통해 서버와 통신하며, 만료된 Access Token을 Refresh Token으로 재발급받을 수 있다.

4. Access Token 전달 및 리다이렉트

  • 사용자가 OAuth2 로그인을 성공하면, 서버는 Access Token을 URL 쿼리 파라미터로 전달하여 클라이언트를 지정된 경로로 리다이렉트한다. 클라이언트는 이 Access Token을 저장하고, 이후 서버로의 요청마다 헤더에 포함해 인증에 사용.

5. Access Token 사용

  • 클라이언트는 Access Token을 포함하여 API 요청을 보낸다. 이 토큰은 서버 측에서 검증되며, 사용자의 인증 상태를 유지하는 데 사용된다. Access Token이 만료되면 Refresh Token을 사용해 새 Access Token을 발급받을 수 있다.

6. Refresh Token을 통한 Access Token 재발급

  • 만료된 Access Token: Access Token이 만료되었을 경우, 클라이언트는 저장된 Refresh Token을 서버로 보내 새로운 Access Token을 요청한다. TokenService는 Refresh Token을 검증한 후 새로운 Access Token을 발급한다.
  • @RequiredArgsConstructor @Service public class TokenService { private final TokenProvider tokenProvider; private final RefreshTokenService refreshTokenService; private final UserService userService; public String createNewAccessToken(String refreshToken) { // 토큰 유효성 검사에 실패하면 예외 발생 if(!tokenProvider.validToken(refreshToken)) { throw new IllegalArgumentException("Unexpected token"); } Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId(); User user = userService.findById(userId); return tokenProvider.generateToken(user, Duration.ofHours(2)); } }

 

 

리프레시 토큰을 쿠키에 저장하는 이유: 보안과 편의성

  1. 자동 전송: 쿠키에 저장된 리프레시 토큰은 사용자가 서버로 요청을 보낼 때마다 자동으로 전송된다. 사용자는 리프레시 토큰을 직접 관리할 필요가 없다.
  2. 보안: 리프레시 토큰은 HTTPOnlySecure 속성을 통해 브라우저 스크립트에서 접근하지 못하도록 보호된다. 또한 HTTPS를 사용해 통신 중에 토큰이 탈취되는 것을 방지한다.이지원
    • 막강한 인증(Authentication)과 인가(Authorization 혹은 권한 부여 기능)을 가진 프레임워크
    • 스프링 가반 어플리케이션에서의 보안을 위한 표준
    • 확장성을 고려한 프레임워크다보니 다양한 요구사항을 손쉽게 추가하고 변경할 수 있음