OAuth2 + JWT 이용, 인증/인가 구현 (without Spring Security)

Minjae An·2024년 1월 11일
1

Spring ETC

목록 보기
1/8
post-thumbnail

프로젝트 구성

  • Java 17
  • SpringBoot 3.2.1
  • Lombok
  • Spring Web
  • Spring data JPA
  • Spring Webflux
  • MySQL Driver
  • auth0:java-jwt 4.4.0

OAuth 클라이언트는 구글을 이용하였다.

패키지 구조

application.yml

spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PW}
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
        hbm2ddl:
          auto: create
        show-sql: true

oauth2:
  login-url: https://accounts.google.com/o/oauth2/v2/auth
  client-id: ${OAUTH_CLIENT_ID}
  client-pw: ${OAUTH_CLIENT_PW}
  redirect-uri: ${OAUTH_CALLBACK_URL}
  token-uri: https://oauth2.googleapis.com/token
  user-info-uri: https://www.googleapis.com/userinfo/v2/me

jwt:
  secret-key: ${JWT_SECRET}

${} 로 표시된 값들은 실제 값을 넣어야 한다. (혹은 환경변수 등으로 설정하여 주입해주어야 한다)

인증 절차

인증 절차를 그림으로 정리해보면 다음과 같다.

프론트 측에서 client-id , client-secret 을 가지고 OAuth 인증 URL를 직접 접근할 수도 있지만 서버에서 해당 URL을 먼저 내려주고 인증 절차를 진행하는 것으로 구성하였다.

인가 절차

인가 절차는 다음과 같이 정리할 수 있다.

기본 엔티티

User

먼저 사용자를 나타낼 엔티티 클래스를 구성하였다.

package com.example.springallinoneproject.user.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
@Getter
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String username;
    private String password;

    @Enumerated(value = EnumType.STRING)
    private SocialType socialType;

    @Builder
    public User(String email, String username, String password, SocialType socialType) {
        this.email = email;
        this.username = username;
        this.password = password;
        this.socialType = socialType;
    }
}

이 프로젝트에서는 구글 OAuth 만을 이용하여 소셜 로그인을 구현하였지만 추후 다른 OAuth로 확장될 가능성을 염두해두고 SocialType 열거형을 소셜 로그인 타입을 표현하였다.

SocialType

package com.example.springallinoneproject.user.entity;

public enum SocialType {
    GOOGLE, NAVER, GITHUB
}

인증 기능 구현

이 프로젝트에서 구현한 구글 소셜 로그인은 다음 절차를 걸쳐 이뤄진다.

  1. 사용자가 제공한 URL을 통해 인증 절차를 마치면 구글 OAuth client에서 설정했던 콜백 URL을 통해 code 가 쿼리스트링으로 제공된다.
  2. 해당 code 를 통해 유저 리소스에 접근할 수 있는 토큰은 [oauth2.googleapis.com/token](http://oauth2.googleapis.com/token) 에 공식 문서에서 정의한 바디 값을 포함한 POST 요청을 통해 받을 수 있다.
  3. [www.googleapis.com/userinfo/v2/me](http://www.googleapis.com/userinfo/v2/me) 에 접근 토큰을 쿼리스트링으로 포함하여 GET 요청해 이메일, 유저 이름과 같은 데이터를 받을 수 있다.
  4. 받은 이메일, 유저 이름 데이터를 바탕으로 유저 데이터를 생성하여 데이터베이스에 저장한다.
  5. 유저 데이터를 바탕으로 JWT를 생성하여 클라이언트에 응답한다.

LoginService

package com.example.springallinoneproject.user.login;

import com.example.springallinoneproject.user.dto.LoggedInUser;
import com.example.springallinoneproject.user.dto.UserProfileResponse;
import com.example.springallinoneproject.user.entity.SocialType;
import com.example.springallinoneproject.user.entity.User;
import com.example.springallinoneproject.user.repository.UserRepository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;

@Service
@RequiredArgsConstructor
@Slf4j
public class LoginService {
    private final UserRepository userRepository;

    @Value("${oauth2.login-url}")
    private String loginUrl;

    @Value("${oauth2.client-id}")
    private String clientId;

    @Value("${oauth2.client-pw}")
    private String clientPassword;

    @Value("${oauth2.redirect-uri}")
    private String redirectUri;

    @Value("${oauth2.token-uri}")
    private String tokenUri;

    @Value("${oauth2.user-info-uri}")
    private String userInfoUri;

    public String getUrl() {
        return loginUrl + "?client_id=" + clientId + "&redirect_uri=" + redirectUri
                + "&response_type=code&scope=email profile";
    }

    public String getToken(String code) {
        WebClient webClient = WebClient
                .builder()
                .baseUrl("https://oauth2.googleapis.com")
                .build();

        HashMap<String, Object> requestBody = new HashMap<>();
        requestBody.put("code", code);
        requestBody.put("client_id", clientId);
        requestBody.put("client_secret", clientPassword);
        requestBody.put("redirect_uri", redirectUri);
        requestBody.put("grant_type", "authorization_code");

        Map<String, Object> response = webClient.post()
                .uri(uriBuilder -> uriBuilder
                        .path("/token")
                        .queryParam("code", code)
                        .build())
                .header(HttpHeaders.ACCEPT, "application/json")
                .bodyValue(requestBody)
                .retrieve()
                .bodyToMono(Map.class)
                .block();

        return (String) response.get("access_token");
    }

    public UserProfileResponse getUserProfile(String accessToken) {
        WebClient webClient = WebClient
                .builder()
                .baseUrl("https://www.googleapis.com")
                .build();

        Map<String, Object> response = webClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("userinfo/v2/me")
                        .queryParam("access_token", accessToken)
                        .build())
                .retrieve()
                .bodyToMono(Map.class)
                .block();

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

        return UserProfileResponse.builder()
                .email(email)
                .username(name)
                .build();
    }

    public LoggedInUser createUser(UserProfileResponse userProfile) {
        String email = userProfile.getEmail();
        Optional<User> signedUser = userRepository.findByEmail(email);

        if (signedUser.isPresent()) {
            return new LoggedInUser(signedUser.get());
        }

        User user = User.builder()
                .email(userProfile.getEmail())
                .username(userProfile.getUsername())
                .password("1234")
                .socialType(SocialType.GOOGLE)
                .build();
        return new LoggedInUser(userRepository.save(user));
    }
}
  • 요청에 사용되는 URL 필드들은 application.yml 에 명시된 값을 @Value 어노테이션을 통해 주입받도록 구성하였다.
  • 외부 API를 호출하는 데는 WebClient를 사용하였다.

JwtUtil

JWT를 생성하고 검증하는 기능들을 한 클래스에서 전담하여 제공할 수 있도록 JwtUtil 클래스를 구현하였다.

package com.example.springallinoneproject.user.login;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.springallinoneproject.user.dto.LoggedInUser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class JwtUtil {
    @Value("${jwt.secret-key}")
    private String secret;
    private int expirationTimeMillis = 864_000_000; // 10일(밀리 초 단위)
    private String tokenPrefix = "Bearer ";
    private ObjectMapper objectMapper = new ObjectMapper();

    public boolean isIncludeTokenPrefix(String header) {
        return header.split(" ")[0].equals(tokenPrefix.trim());
    }

    public String extractTokenFromHeader(String header) {
        return header.replace(tokenPrefix, "");
    }

    public String createToken(LoggedInUser loggedInUser, Instant currentDate) {
        String token = JWT.create()
                .withSubject(loggedInUser.getEmail())
                .withExpiresAt(currentDate.plusMillis(expirationTimeMillis))
                .withClaim("email", loggedInUser.getEmail())
                .withClaim("username", loggedInUser.getUsername())
                .sign(Algorithm.HMAC512(secret));

        return tokenPrefix.concat(token);
    }

    public boolean isTokenExpired(String token) {
        Instant expiredAt = JWT.require(Algorithm.HMAC512(secret))
                .build().verify(token)
                .getExpiresAtAsInstant();

        return expiredAt.isBefore(Instant.now());
    }

    public boolean isTokenNotManipulated(String token) {
        return JWT.require(Algorithm.HMAC512(secret))
                .build().verify(token)
                .getSignature()
                .equals(secret);
    }

    public LoggedInUser extractUserFromToken(String token) {
        String payload = JWT.decode(token)
                .getPayload();

        byte[] decodedBytes = Base64.getDecoder().decode(payload);
        String decodedPayload = new String(decodedBytes);

        return parseUserFromJwt(decodedPayload);
    }

    private LoggedInUser parseUserFromJwt(String decodedPayload) {
        try {
            LinkedHashMap<String, Object> payloadMap = objectMapper.readValue(decodedPayload, LinkedHashMap.class);
            String email = (String) payloadMap.get("email");
            String username = (String) payloadMap.get("username");
            return new LoggedInUser(email, username);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

LoginController

package com.example.springallinoneproject.user.login;

import com.example.springallinoneproject.user.dto.JwtResponse;
import com.example.springallinoneproject.user.dto.LoggedInUser;
import com.example.springallinoneproject.user.dto.UserProfileResponse;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class LoginController {
    private final LoginService loginService;
    private final JwtUtil jwtUtil;

    @GetMapping("/login")
    public String login() {
        return loginService.getUrl();
    }

    @GetMapping("/login/oauth2/code/google")
    public ResponseEntity<JwtResponse> oauthLogin(String code) {
        String accessToken = loginService.getToken(code);
        UserProfileResponse userProfile = loginService.getUserProfile(accessToken);
        LoggedInUser loggedInUser = loginService.createUser(userProfile);
        String token = jwtUtil.createToken(loggedInUser, Instant.now());

        return ResponseEntity.ok(new JwtResponse(token));
    }

    @GetMapping("/api/v1/user")
    public String user(){
        return "<h1>user</h1>";
    }

    @GetMapping("/api/v1/manager")
    public String manager(){
        return "<h1>manager</h1>";
    }
}

프론트에서는 /login 에 GET 요청을 통해 구글 소셜 로그인 URL을 받을 수 있으며 해당 URL을 통해 유저가 인증 절차를 다 완료할 경우 콜백 URL을 통해 code 가 넘어온다. /login/oauth2/code/google 경로로 콜백되도록 구성하였다. 유저 데이터를 기반으로 생성된 JWT는 JwtResponse DTO 형태로 응답 바디를 통해 클라이언트에게 전달된다.

JwtResponse

package com.example.springallinoneproject.user.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class JwtResponse {
    private String token;
}

인가 기능 구현

로그인 여부를 검증하는 기능과 JWT를 검증하는 기능을 다른 인터셉터로 나누어 구현하였다. 흐름은 로그인 여부를 먼저 검증하고(Authorization 헤더의 존재 여부) JWT를 검증하는 식이다.

LoginInterceptor

package com.example.springallinoneproject.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {

    private final JwtInterceptor jwtInterceptor;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if(request.getHeader(HttpHeaders.AUTHORIZATION)==null){
            throw new IllegalStateException("no Authorization");
        }

        jwtInterceptor.preHandle(request, response, handler);

        return true;
    }
}

Authorization 헤더가 존재하면 JwtInterceptor 에게 JWT 검증을 위임한다.

JwtInterceptor

package com.example.springallinoneproject.interceptor;

import com.example.springallinoneproject.user.login.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
    private final JwtUtil jwtUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if(!jwtUtil.isIncludeTokenPrefix(header)){
            throw new IllegalStateException("No Bearer prefix");
        }

        String token = jwtUtil.extractTokenFromHeader(header);
        if(jwtUtil.isTokenExpired(token)){
            throw new IllegalStateException("token expired");
        }

        if(jwtUtil.isTokenNotManipulated(token)){
            throw new IllegalStateException("token manipulated");
        }

        return true;
    }
}

Bearer 토큰 여부, 토큰 만료 여부, 토큰 시그니처를 검증한다.

WebConfig

구현한 인터셉터를 등록하여 잘 동작할 수 있도록 설정한다.

package com.example.springallinoneproject.config;

import com.example.springallinoneproject.interceptor.LoginInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/api/v1/user/**")
                .addPathPatterns("/api/v1/manager/**");
    }
}

전체 소스 코드

profile
먹고 살려고 개발 시작했지만, 이왕 하는 거 잘하고 싶다.

0개의 댓글