위 두 생각의 종합으로 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 로그인 과정을 구현하는 방식으로 코드 리팩토링을 진행하고자 합니다.