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

인증 프로세스는 위와 같이 진행됩니다.
Spring Boot OAuth2 Client에 의해 OAuth2 인증 절차가 진행됩니다.OAuth2AuthenticationSuccessHandler가 GitHub 유저 정보를 기반으로 MoGakGo 유저 관리, JWT 토큰 생성 및 저장을 진행합니다.OAuth2AuthenticationSuccessHandler는 OAuth2 로그인이 성공적으로 진행됬을때 로그인 반환 정보를 핸들링합니다.@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");
}
...
}
AuthUserServie는 GitHub OAuth로 로그인 후 전달받은 유저 정보 기반으로 MoGakGo 사용자를 관리하는 서비스입니다.@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는 전달받은 MoGakGo UserId를 활용해 Access Token과 Refresh Token을 생성해줍니다.@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);
}
...
}
JwtHelper를 통해 발급한 Refresh Token과 Access Token을 Redis에 <Key, Value> 형태로 저장해줍니다.@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는 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);
}
...
}
기능은 정상적으로 동작하지만 아래와 같은 의문과 아쉬움을 남겼습니다.
이러한 문제를 개선하고자 Spring OAuth2 Client를 사용하지 않고 직접 OAuth2 로그인 과정을 구현하는 방식으로 코드 리팩토링을 진행하고자 합니다.