[MoGakGo] GitHub OAuth2 + JWT 활용 인증 구현하기

David Lee·2024년 3월 26일

MoGakGo

목록 보기
1/5
post-thumbnail

왜 GitHub OAuth2를 사용했을까?

  • MoGakGo는 모각코를 모집한다는 점에서 개발자가 메인 타겟층이였고, 개발자라면 GitHub를 모를수가 없다! 라는 생각에 로그인 과정에서 GitHub를 활용하면 더 간편하게 접근할 수 있을거라는 생각
  • 간단함과 랜덤을 추구하는 프로젝트의 목표에 부합하면서 모각코를 같이 할사람의 최소한의 정보로 사용자가 자주 사용하는 개발 언어 정보를 GitHub를 통해 추가(링크)하려면 GitHub 정보가 필요하다!

위 두 생각의 종합으로 GitHub OAuth2를 사용하는 것이 결정되었습니다. 하지만 단순히 OAuth2를 활용한 로그인 방식은 FE, BE간 API 호출 방식을 활용하기로 한 프로젝트 진행방식과 적합하지 않아 GitHub OAuth2 + JWT를 활용한 토큰 기반 인증 방식 구현을 진행했습니다.

전반적인 인증 프로세스 살펴보기

인증 프로세스

인증 프로세스는 위와 같이 진행됩니다.

  • Client에서 GitHub OAuth2 로그인 링크로 GitHub 로그인을 진행합니다.
  • 로그인 링크를 통해 Callback이 MoGakGoServer로 전달되고 Spring Boot OAuth2 Client에 의해 OAuth2 인증 절차가 진행됩니다.
  • 인증 절차가 완료된 이후 OAuth2AuthenticationSuccessHandler가 GitHub 유저 정보를 기반으로 MoGakGo 유저 관리, JWT 토큰 생성 및 저장을 진행합니다.
  • 생성된 JWT 토큰을 Client에 전달합니다.

세부 구현 살펴보기

OAuth2AuthenticationSuccessHandler

  • OAuth2AuthenticationSuccessHandler는 OAuth2 로그인이 성공적으로 진행됬을때 로그인 반환 정보를 핸들링합니다.
    • GitHub 로그인 정보를 활용해 유저 생성 요청, JWT 토큰생성을 진행하고 이에 따른 결과를 리다이렉트 해줍니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtHelper jwtHelper;
    private final JwtRedisDao jwtRedisDao;
    private final AuthUserService authUserService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws IOException, ServletException {
        log.info("OAuth2 Login Success -> onAuthenticationSuccess");
        // 인증 완료된 유저 정보를 불러오기
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        // 인증 완료된 유저 정보 기반 유저 엔티티 관리 및 결과 반환
        var userOAuth2Response = manageUserEntity(Long.parseLong(oAuth2User.getName()), oAuth2User);
        // JWT 토큰 생성 및 저장
        var jwtToken = generateJwtToken(userOAuth2Response);
        // 완료된 인증 정보 기반 새로운 authentication 생성
        oAuth2User = generateAuthentication(userOAuth2Response, jwtToken);
        // 새로운 authentication을 SecurityContextHolder에 저장
        authentication = new JwtAuthenticationToken(oAuth2User, null,
            oAuth2User.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 로그인 성공 페이지로 리다이렉트
        response.sendRedirect("/oauth2/login/success");
    }
    ...
}

AuthUserService

  • AuthUserServie는 GitHub OAuth로 로그인 후 전달받은 유저 정보 기반으로 MoGakGo 사용자를 관리하는 서비스입니다.
    • 이미 회원 가입한 사용자의 경우 회원 정보에 Github 정보를 업데이트하고 사용자가 존재하지 않는 경우 신규 사용자를 생성 및 반환합니다.
@Service
@RequiredArgsConstructor
public class AuthUserService {

    private final UserJpaRepository userRepository;

    @Transactional
    public UserOAuth2Response manageOAuth2User(long githubPk, String githubId, String avatarUrl,
        String githubUrl,
        String repositoryUrl) {
        User user = userRepository.findByGithubPk(githubPk).orElseGet(() -> userRepository.save(
            User.of(githubPk, githubId, avatarUrl, githubUrl, repositoryUrl)));
        user.updateGithubInfo(githubId, avatarUrl, githubUrl, repositoryUrl);
        return UserOAuth2Response.from(user);
    }


}

JwtHelper

  • JwtHelper는 전달받은 MoGakGo UserId를 활용해 Access Token과 Refresh Token을 생성해줍니다.
  • JWT 토큰 검증 및 재발급 과정도 담당하고 있습니다.
@Component
public class JwtHelper {

    public static final String USER_ID_STR = "userId";
    public static final String ROLES_STR = "roles";
    private static final Long HOUR_TO_MILLIS = 3600000L;

    private final String issuer;
    private final long accessTokenExpirySeconds;
    private final long refreshTokenExpirySeconds;
    private final Algorithm algorithm;
    private final JWTVerifier jwtVerifier;

    public JwtHelper(JwtProperties jwtProperties) {
        this.issuer = jwtProperties.getIssuer();
        this.accessTokenExpirySeconds = hoursToMillis(jwtProperties.getAccessTokenExpiryHour());
        this.refreshTokenExpirySeconds = hoursToMillis(jwtProperties.getRefreshTokenExpiryHour());
        this.algorithm = Algorithm.HMAC256(jwtProperties.getClientSecret());
        this.jwtVerifier = require(algorithm).withIssuer(issuer).build();
    }

    public JwtToken sign(long userId, String[] roles) {
        Date now = new Date();
        String accessToken = create()
            .withIssuer(issuer)
            .withIssuedAt(now)
            .withExpiresAt(calculateExpirySeconds(now, accessTokenExpirySeconds))
            .withClaim(USER_ID_STR, userId)
            .withArrayClaim(ROLES_STR, roles)
            .sign(algorithm);
        Date refreshTokenExpiryDate = calculateExpirySeconds(now, refreshTokenExpirySeconds);
        String refreshToken = create()
            .withIssuer(issuer)
            .withIssuedAt(now)
            .withExpiresAt(refreshTokenExpiryDate)
            .sign(algorithm);
        return JwtToken.of(userId, accessToken, refreshToken,
            (int) refreshTokenExpirySeconds / 1000);
    }
	...
}

JwtRedisDao

  • JwtHelper를 통해 발급한 Refresh Token과 Access Token을 Redis에 <Key, Value> 형태로 저장해줍니다.
    • TTL을 적용해 토큰 재발급 과정에서 Refresh Token의 만료를 보조합니다.
@Slf4j
@Service
public class JwtRedisDao {

    private final RedisTemplate<String, String> redisTemplate;
    private final int refreshExpireHour;

    public JwtRedisDao(StringRedisTemplate redisTemplate, JwtProperties jwtProperties) {
        this.redisTemplate = redisTemplate;
        this.refreshExpireHour = jwtProperties.getRefreshTokenExpiryHour();
    }

    @Transactional
    public void saveTokens(String accessToken, String refreshToken) {
        redisTemplate.opsForValue()
            .set(accessToken, refreshToken, refreshExpireHour, TimeUnit.HOURS);
    }

    @Transactional
    public void saveTokens(String accessToken, String refreshToken, int expireHour) {
        redisTemplate.opsForValue()
            .set(accessToken, refreshToken, expireHour, TimeUnit.SECONDS);
    }

    @Transactional(readOnly = true)
    public String getRefreshTokenByAccessToken(String accessToken) {
        var result = Optional.ofNullable(redisTemplate.opsForValue().get(accessToken));
        return result.orElseThrow(() -> {
            log.debug("refreshToken not found");
            return new AuthException(ErrorCode401.AUTH_MISSING_CREDENTIALS);
        });
    }
}

OAuth2Controller

  • OAuth2Controller는 OAuth2AuthenticationSuccessHandler에서 리다이렉트한 정보를 프론트엔드로 전달해줍니다.
@Slf4j
@RestController
@RequestMapping("/oauth2")
public class OAuth2Controller implements OAuth2Swagger {
    private final String serverUrl;
    private final String clientUrl;

    public OAuth2Controller(@Value("${auth.server-url}") String serverUrl,
        @Value("${auth.client-url}") String clientUrl) {
        this.serverUrl = serverUrl;
        this.clientUrl = clientUrl;
    }

    @GetMapping("/login/success")
    public void loginSuccess(@AuthenticationPrincipal OAuth2User oAuth2User,
        HttpServletResponse response) throws IOException {
        String accessToken = oAuth2User.getAttributes().get("accessToken").toString();
        boolean signUpComplete = (boolean) oAuth2User.getAttributes().get("signUpComplete");
        String refreshToken = oAuth2User.getAttributes().get("refreshToken").toString();
        String redirectUrl = signUpComplete ? clientUrl : clientUrl + "/signup";
        response.sendRedirect(redirectUrl + "?accessToken=" + accessToken + "&refreshToken=" + refreshToken);
    }
    ...
}

기능 구현은 끝났지만...

기능은 정상적으로 동작하지만 아래와 같은 의문과 아쉬움을 남겼습니다.

  • 프론트앤드의 뷰로 직접 리다이렉트를 해줘야 한다.
    • 프론트앤드에서 요청을 보내고 응답을 전송하는 방식이 아닌 GitHub 로그인의 Callback에서 부터 이어지는 기능이기에 응답 전송을 직접 해줘야 한다는 단점이 있습니다.
  • Spring OAuth2 Client는 Session이 stateless한 상태에서 동작하지 않는다.
    • Spring OAuth2는 인증 과정에서 인증 정보에 의한 콜백을 줄이기 위해 인증 정보를 세션에 저장해 활용합니다.
    • OAuth2 로그인 로직만 세션 상태를 stateful하게 관리해야합니다.

이러한 문제를 개선하고자 Spring OAuth2 Client를 사용하지 않고 직접 OAuth2 로그인 과정을 구현하는 방식으로 코드 리팩토링을 진행하고자 합니다.

profile
쌓아가기

0개의 댓글