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

David Lee·2024년 3월 26일
0

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

  • OAuth2ControllerOAuth2AuthenticationSuccessHandler에서 리다이렉트한 정보를 프론트엔드로 전달해줍니다.
@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개의 댓글

관련 채용 정보