728x90
서론
OAuth의 기본 개념 소개
- OAuth의 핵심은 인증과 인가를 구분하는 것
- 인증 : 사용자가 자신의 신원을 증명하는 것
- ex) id, pw로 로그인
- 인가: 인증된 사용자에게 특정 리소스에 대한 접근 권한을 부여하는 과정
- ex) access token
- 인증과 인가를 구분함으로써 인증과 인가 로직을 따로 설정할 수 있습니다.
- 인증은 2단계 인증으로, 인가는 세밀한 권한 제어 설정이 가능합니다.
- 인증 : 사용자가 자신의 신원을 증명하는 것
- OAuth가 탄생한 이유는 보안 수준이 검증되지 않은 여러 플랫폼에 동일한 로그인 정보를 사용하는 위험을 줄이기 위해서입니다.
- 신뢰할 수 있는 플랫폼이 인증과 권한 부여를 담당함으로써, 사용자는 더 안전하게 여러 서비스를 이용할 수 있게 됩니다.
로그인 방식
1. 프론트엔드에서 직접 소셜 로그인 요청을 보내는 방식
- 구현이 간단하고 빠릅니다.
- 서버 부하가 감소합니다.
- 클라이언트 시크릿을 프론트엔드에 노출시킬 위험이 있습니다.
- OAuth 2.0 명세에 따르면 권장되지 않습니다.
- 토큰 관리시 보안상 좋지 않습니다.
2. 백엔드를 통해 소셜 로그인을 처리하는 방식 (추천)
- 클라이언트 시크릿을 안전하게 보관할 수 있습니다.
- 토큰 관리를 서버에서 하므로(관리 중앙화) 보안이 강화됩니다.
- 유저의 시크릿이 유출되지 않습니다.
- 권한 서버(ex) 카카오)의 토큰을 서버에 저장하고, 액세스 토큰과 리프레시 토큰은 서버(우리 서비스)가 발급해서 줍니다.
- 사용자 정보를 서버 측에서 검증할 수 있습니다.
- 구현이 조금 더 복잡하고, 서버에 추가적으로 부하가 발생할 수 있습니다.
블로그 글은 백엔드를 통해 소셜 로그인을 구현하였습니다.
2. OAuth 라이브러리의 편리함
라이브러리가 제공하는 기능들
- 자동 URL 생성:
/oauth2/authorization/{provider-id}
- 인증 요청 리다이렉트:
OAuth2AuthorizationRequestRedirectFilter
가 자동으로 OAuth 제공자의 로그인 페이지로 리다이렉트합니다. - 콜백 처리: 인증 코드를 받아 처리하는 과정을
OAuth2LoginAuthenticationFilter
가 자동으로 수행합니다. - 토큰 요청 및 갱신: 액세스 토큰 요청, 갱신 등의 과정을 자동으로 처리합니다.
- 사용자 정보 로드: OAuth 제공자로부터 사용자 정보를 자동으로 가져옵니다.
개발자가 작성할 부분
- 사용자 데이터 DB 저장
- JWT 토큰 생성 및 관리
- 커스텀 인증 로직 구현
OAuth의 기술 컨셉만 제대로 이해했다면 구현 과정이 훨씬 빠르고 쉬웠을 것 같다.
프로바이더에 독립적인 액세스 토큰 생성하기
프로바이더 토큰을 바로 사용하면 안된다
- 외부 의존성 증가: 프로바이더의 토큰 형식이나 정책이 변경되면 서비스에 영향을 미친다.
- 보안 문제: 프로바이더 토큰 노출시 프로바이더가 제공하는 다른 서비스에 악용될 수 있다.
- 설정 제한 : 토큰에 담길 정보를 결정할 수 없다.
자체적으로 액세스 토큰을 생성하자
- 프로바이더 토큰은 그대로 두고, 자체 액세스 토큰을 생성하면 위의 단점들을 극복할 수 있다.
- 독립성: OAuth2 프로바이더에 종속되지 않고 여러 프로바이더를 지원할 수 있다.
- 보안 : 토큰 노출시 리스크가 줄어든다.
- 토큰 관리가 쉽다.
4. 안전한 토큰 전달 방식
리다이렉트 URI에 쿼리로 토큰을 전달하는 것은 위험하다.
- 많은 프로젝트에서 인증 후 액세스 토큰을 리다이렉트 URI의 쿼리 파라미터로 전달하였다.
- 하지만 액세스 토큰이 URL에 노출되는 문제가 있고, 브라우저 히스토리에 저장되어 후에 접근이 가능하다.
- 보안상 위험이 있으므로 다른 방식을 생각해볼 필요가 있다.
- 리다이렉트(3xx) 시에는 응답 body로 데이터를 직접 보낼 수 없는 HTTP 프로토콜의 한계도 고려해야한다.
임시 토큰(temp token) 발급 방식
- 서버에서 임시 토큰을 생성하여
access token
대신 전달한다. - 클라이언트는 임시 토큰을 통해
access token
을 요청할 수 있다.
임시 토큰을 이용한 액세스 토큰 발급 과정
- 임시 토큰 생성: 인증 성공 후, 서버는
UUID
를 사용하여 임시 토큰을 생성한다. - Redis에 저장: 생성된 임시 토큰과 실제 액세스 토큰을 키-값 쌍으로
Redis
에 저장한다. - 클라이언트에게 임시 토큰 전달
- 액세스 토큰 요청 - 응답
- 임시 토큰 삭제 (일회용)
임시 토큰 저장 방식
세션 저장 vs Redis 저장
- 세션 저장
- 처음에 구현의 간단함을 이유로 세션에 저장하였다.
- 하지만 서버 재시작시 데이터가 손실되고, 다중 서버에서 동기화 문제가 발생 가능하였다.
- Redis 사용
- Redis를 사용하면 외부 서버이므로 서버 재시작과 관계없이 데이터가 유지된다.
- 분산 환경에서도 데이터를 일관적으로 사용이 가능하다.
- 만료 시간 설정을 쉽게 할 수 있다.
OAuth 소셜 로그인 구현하기
- java 17
- Spring Boot 3.2.5
- Spring Security 6.2.4
1. OAuth2 클라이언트 설정
application.yml
# OAuth
spring.security:
oauth2.client:
registration:
google:
clientId: ${GOOGLE_CLIENT_ID}
clientSecret: ${GOOGLE_CLIENT_SECRET}
redirectUri: http://localhost:8080/login/oauth2/code/google
scope:
kakao:
clientId: ${KAKAO_CLIENT_ID}
clientSecret: ${KAKAO_CLIENT_SECRET}
clientAuthenticationMethod: client_secret_post
authorizationGrantType: authorization_code
redirectUri: http://localhost:8080/login/oauth2/code/kakao
scope:
clientName: Kakao
# Provider 설정
provider:
kakao:
authorizationUri: https://kauth.kakao.com/oauth/authorize
tokenUri: https://kauth.kakao.com/oauth/token
userInfoUri: https://kapi.kakao.com/v2/user/me
userNameAttribute: id
app:
auth:
jwt:
secret-key: ${JWT_SECRET_KEY}
access-expired: ${ACCESS_EXPIRED}
refresh-expired: ${REFRESH_EXPIRED}
temp-token:
expiration-minutes: 5
oauth2:
redirect-uris:
- http://localhost:3000/login/oauth2/redirect
- https://{배포주소}/login/oauth2/redirect
default-redirect-uri: https://{배포주소}/login/oauth2/redirect
2. 사용자 정보 처리 서비스 구현
CustomOAuth2UserService
클래스를 만들어 OAuth2 인증 후 사용자 정보를 처리한다.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User user = super.loadUser(userRequest);
try {
return this.process(userRequest, user);
} catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
AuthProvider authProvider = AuthProvider.valueOf(
userRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2MemberInfo memberInfo = OAuth2MemberInfoFactory.getOAuth2MemberInfo(authProvider, user.getAttributes());
Optional<Member> memberOptional = memberRepository.findByOauthId(memberInfo.getId());
Member member;
if (memberOptional.isPresent()) {
member = memberOptional.get();
if (authProvider != member.getAuthProvider()) {
throw new OAuthProviderMissMatchException();
}
} else {
member = createMember(memberInfo, authProvider);
}
return CustomUserDetails.create(member, user.getAttributes());
}
private Member createMember(OAuth2MemberInfo memberInfo, AuthProvider authProvider) {
Member member = Member.createMemberWithOAuthInfo(memberInfo, authProvider);
return memberRepository.save(member);
}
}
3. 인증 성공 핸들러 구현
OAuth2AuthenticationSuccessHandler
클래스를 만들어 인증 성공 시 처리를 구현한다.
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final AuthService authService;
private final AppProperties appProperties;
private final RedisTemplate<String, String> redisTemplate;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("응답이 이미 커밋되었습니다. " + targetUrl + "로 리다이렉트 할 수 없습니다");
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if(redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("승인되지 않은 리다이렉션 URI입니다");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
String token = tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", token)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
return appProperties.getOauth2().getAuthorizedRedirectUris()
.stream()
.anyMatch(authorizedRedirectUri -> {
URI authorizedURI = URI.create(authorizedRedirectUri);
return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedURI.getPort() == clientRedirectUri.getPort();
});
}
}
4. 보안 설정
SecurityConfig
클래스에서 OAuth2 로그인 설정을 추가한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf()
.disable()
..
// OAuth2 로그인
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authorization -> authorization
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository))
.userInfoEndpoint(userInfo -> userInfo
.userService(oAuth2UserService))
.successHandler(successHandler)
).oauth2Client(Customizer.withDefaults());
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
5. 회원 정보 클래스
인증을 통해 얻은 회원 정보에 대응되는 클래스이다.
public abstract class OAuth2MemberInfo {
protected Map<String, Object> attributes;
public OAuth2MemberInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
}
kakao
public class KakaoOAuth2MemberInfo extends OAuth2MemberInfo {
public KakaoOAuth2MemberInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return attributes.get("id").toString();
}
}
Provider에 따라 회원 정보 생성하는 클래스
public class OAuth2MemberInfoFactory {
public static OAuth2MemberInfo getOAuth2MemberInfo(AuthProvider authProvider, Map<String, Object> attributes) {
switch (authProvider) {
case KAKAO -> {
return new KakaoOAuth2MemberInfo(attributes);
}
case GOOGLE -> {
return new GoogleOAuth2MemberInfo(attributes);
}
default -> throw new UnsupportedProviderException();
}
}
}
6. OAuth2 인증 저장소
OAuth2
인증 요청을 HTTP 쿠키에 저장하고 관리하는 Repository 클래스이다.OAuth2
인증 프로세스에서 Spring Security OAuth2 클라이언트에 의해 호출되며, 인증 과정에서 상태를 유지하는 데 사용된다.
```java
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements
AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
/**
* 로그인 수행시, OAuth2 인증 요청을 HTTP 쿠키에 저장한다. 리다이렉트 URI도 함께 저장한다.
* * @param authorizationRequest 저장할 OAuth2 인증 요청
* @param request HTTP 요청
* @param response HTTP 응답
*/
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request,
HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequestCookies(request, response);
return;
}
addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
/**
* 로그인 완료 후 우리 서비스로 복귀할 때 사용한다. 쿠키로부터 HTTP 요청에서 저장했던 OAuth2 인증 요청을 로드한다.
* * @param request HTTP 요청
* @return 저장된 OAuth2 인증 요청, 없으면 null
*/ @Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> deserialize(cookie.getValue(), OAuth2AuthorizationRequest.class))
.orElse(null);
}
/**
* 인증이 끝났으니 사용했던 쿠키들을 제거한다.
* * @param request HTTP 요청
* @param response HTTP 응답
* @return 저장된 OAuth2 인증 요청, 없으면 null
*/ @Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
/**
* OAuth2 인증 요청과 관련된 쿠키들을 제거한다.
* * @param request HTTP 요청
* @param response HTTP 응답
*/
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
/**
* 저장된 리다이렉트 URI를 가져온다.
* * @param request HTTP 요청
* @return 저장된 리다이렉트 URI, 없으면 null
*/ public String getRedirectUriAfterLogin(HttpServletRequest request) {
return getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(cookie -> cookie.getValue())
.orElse(null);
}
}
7. 임시 토큰 발급 및 관리
인증 성공 후 임시 토큰을 발급하고 Redis에 저장한다.
String tempToken = UUID.randomUUID().toString();
redisTemplate.opsForValue()
.set(tempToken, accessToken, appProperties.getAuth().getTempToken().getExpirationMinutes(),
TimeUnit.MINUTES);
클라이언트는 이 임시 토큰을 사용하여 실제 액세스 토큰을 요청한다.
@PostMapping("/token")
public ResponseEntity<Map<String, String>> getAccessToken(@RequestParam("temp-token") String tempToken) {
String accessToken = redisTemplate.opsForValue().get(tempToken);
if (accessToken != null) {
redisTemplate.delete(tempToken);
return ResponseEntity.ok(Map.of("access_token", accessToken));
}
return ResponseEntity.badRequest().build();
}
처음 사용한 기술도 있었지만 생각보다 구현이 잘 되어서 즐거웠다.
728x90
'문제&해결' 카테고리의 다른 글
Redis 도입하기 : CI/CD 파이프라인 수정기 (Blue/Green 무중단 배포) (12) | 2024.10.08 |
---|---|
테스트 작성 부담감 극복하기 - POJO와 통합 테스트 중심의 전략 (0) | 2024.07.05 |
Spring WebSocket 예외 처리 - @MessageExceptionHandler, StompSubProtocolErrorHandler (0) | 2024.06.18 |
Spring WebSocket 애플리케이션에 Spring Security 적용하기(simpUser, Interceptor, handler) (2) | 2024.05.15 |
[Test] @Mock, @InjectMocks 동작 원리 및 주의 사항 (0) | 2024.04.10 |