OAuth2 적용

뚜우웅이·2025년 2월 7일

OAuth란

OAuth는 제3의 서비스에 계정 관리를 맡기는 방식이다. 다른 웹사이트에서 흔히 볼 수 있는 네이버, 구글, 카카오 계정을 이용하여 로그인하는 방식이다.

용어

  • resource owner: 인증 서버에 자신의 정보를 사용하도록 허가해주는 주체이다. 즉 서비스를 이용하는 사용자가 리소스 오너에 해당된다.
  • resource server: 리소스 오너의 정보를 가지며, 리소스 오너의 정보를 보호하는 주체를 의미한다. 네이버, 구글 등이 리소스 서버에 해당된다.
  • authorization server: 클라이언트에게 리소스 오너의 정보에 접근할 수 있는 토큰을 발급하는 역할을 하는 애플리케이션을 의미한다.
  • client apllication: 인증 서버에게 인증을 받고 리소스 오너의 리소스를 사용하는 주체를 의미한다.

리소스 오너 정보를 취득하는 방법

  • 권한 부여 승인 타입: OAuth2에서 가장 잘 알려진 인증 방법이다. 클라이언트가 리소스에 접근하는데 사용하면, 권한에 접근할 수 있는 코드와 리소스 오너에 대한 AccessToken을 발급받는 방식이다.
  • 암시적 승인 타입: 서버가 없는 자바스크립트 웹 애플리케이션 클라이언트에서 주로 사용하는 방법이다. 클라이언트가 요청을 보내면 리소스 오너의 인증 과정 이외에는 권한 코드 교환 등의 별다른 인증 과정을 거치지 않고 AccessToken을 제공받는 방식이다.
  • 리소스 소유자 암호 자격증명 승인 타입: 클라이언트의 패스워드를 이용해서 AccessToken에 대한 사용자의 자격 증명을 교환하는 방싣이다.
  • 클라이언트 자격증명 승인 타입: 클라이언트가 컨텍스트 외부에서 AccessToken을 얻어 특정 리소스에 접근을 요청할 때 사용하는 방식이다.

권한 요청

스프링 부트 서버가 특정 사용자 데이터에 접근하기 위해 권한 서버, 즉 구글이나 카카오 서버에 요청을 보내는 것이다. 요청 URI는 권한 서버마다 다르지만 보통 클라이언트 ID, 라다이렉트 URI, 응답 타입 등을 파라미터로 보낸다.

  • client_id: 인증 서버가 클라이언트에 할당한 고유 식별자이다. 이 값은 클라이언트 애플리케이션을 OAuth 서비스에 등록할 때 서비스에서 생성하는 값
  • redirect_uri: 로그인 성공 시 이동해야 하는 URI
  • response_type: 클라이언트가 제공 받길 원하는 응답 타입
  • scope: 제공받고자 하는 리소스 오너의 정보 목록

구글 로그인

동의 화면 구성

Google Cloud 콘솔에 접속 한 후 오른쪽 상단 콘솔 버튼을 클릭해준다.
이 후 새 프로젝트를 생성한다.

API 및 서비스 -> 사용자 인증 정보 -> 동의 화면 구성에 들어간다.

앱 이름, 이메일 등을 입력하여 준다.

클라이언트 ID 생성

리디렉션 URI: http://localhost:8080/login/oauth2/code/google

생성 후 나오는 클라이언트 ID와 클라이언트 보안 비밀번호는 애플리케이션에서 사용하는 값이다. 이 값은 리소스 오너의 정보에 접근할 때 사용한다.

네이버 로그인

네이버 로그인

리디렉션 URI: http://localhost:8080/login/oauth2/code/naver

OAuth2 구현

application-oauth2.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${oauth2.google.client-id}
            client-secret: ${oauth2.google.client-secret}
            scope:
              - email
              - profile
          naver:
            client-id: ${oauth2.naver.client-id}
            client-secret: ${oauth2.naver.client-secret}
            scope:
              - email
              - name
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
            client-name: Naver

        provider: # 네이버의 provider는 등록되어 있지 않아 사용자가 등록해야 함
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize # 네이버 로그인 창
            token-uri: https://nid.naver.com/oauth2.0/token # 토큰을 받는 URI
            user-info-uri: https://openapi.naver.com/v1/nid/me # 프로필 주소를 받는 URI
            user-name-attribute: response # 회원 벙보를 json 형태로 받는데 response라는 키값으로 네이버가 리턴해줌

의존성 추가

    // OAuth2
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

User 수정

    @Builder
    public User(String email, String password, String nickname, int age, Role role, String provider, String providerId) {
        this.email = email;
        this.password = password;
        this.nickname = nickname;
        this.age = age;
        this.role = role;
        this.provider = provider;
        this.providerId = providerId;
    }

    // 사용자 이름 변경
    public User update(String nickname) {
        this.nickname = nickname;
        return this;
    }

    // 권한 정보 반환
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(new SimpleGrantedAuthority(role.name()));
    }

Config

WebSecurityConfig

.oauth2Login(oauth2 -> oauth2
                        .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
                        .successHandler(oAuth2SuccessHandler))

EncoderConfig
순환 참조 발생이 우려 되어 BCryptPasswordEncoder는 따로 클래스를 빼준다.

@Configuration
public class EncoderConfig {

    @Bean
    public BCryptPasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }
}

기능 구현

CustomOAuth2User

@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {

    private final User user;
    private final Map<String, Object> attributes;

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getAuthorities();
    }

    @Override
    public String getName() {
        return user.getEmail();
    }

    public String getEmail() {
        return user.getEmail();
    }

    public String getRole() {
        return user.getRole().name();
    }
}

OAuth2 인증을 처리할 때 OAuth2User 인터페이스를 구현한 사용자 정의 클래스이다.
OAuth2 로그인 성공 후, Spring Security는 기본적으로 OAuth2User 객체를 사용하여 로그인한 사용자의 정보를 저장한다. 하지만, 기본 OAuth2User는 우리가 원하는 추가 정보를 담을 수 없기 때문에, 사용자 정보를 커스텀하기 위해 CustomOAuth2User 클래스를 만들어서 사용한다.

  • Map<String, Object> attributes
    OAuth2 제공자(Google, Naver 등)에서 전달해 준 사용자 정보.
    예를 들어, Google에서는 sub, email, name 등의 정보가 들어 있고, Naverresponse라는 키 아래에 email, name 등의 정보가 들어 있다.

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String provider = userRequest.getClientRegistration().getRegistrationId();

        Map<String, Object> attributes = oAuth2User.getAttributes();

        // 네이버인 경우 응답 데이터가 "response" 키로 감싸져 있음
        if (provider.equals("naver")) {
            attributes = (Map<String, Object>) attributes.get("response");
        }

        String providerId;

        if (provider.equals("google")) {
            providerId = (String) attributes.get("sub"); // Google의 사용자 ID는 "sub"
        } else if (provider.equals("naver")) {
            providerId = (String) attributes.get("id"); // Naver의 사용자 ID는 "id"
        } else {
            throw new IllegalArgumentException("지원하지 않는 OAuth2 제공자입니다: " + provider);
        }

        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");

        log.info("email -> {}", email);

        User user = userRepository.findByEmail(email)
                .orElseGet(() -> {
                    User newUser = User.builder()
                            .email(email)
                            .password(passwordEncoder.encode("oauth2user")) // 기본 비밀번호 설정 (사용되지 않음)
                            .nickname(name)
                            .age(0) // 기본값
                            .provider(provider)
                            .providerId(providerId)
                            .role(Role.USER)
                            .build();
                    return userRepository.save(newUser);
                });

        return new CustomOAuth2User(user, oAuth2User.getAttributes());
    }
}

Spring SecurityOAuth2 로그인 과정에서 사용자 정보를 가져오고, 필요하면 회원가입까지 처리하는 서비스 클래스이다.

Spring SecurityDefaultOAuth2UserService를 확장하여 OAuth2 제공자(Google, Naver 등)에서 사용자 정보를 가져오고, 회원 정보를 데이터베이스에 저장하는 역할을 한다.

  • OAuth2 제공자로부터 사용자 정보를 가져옵니다.
    super.loadUser(userRequest)를 호출하면, OAuth2 제공자로부터 받은 사용자의 기본 정보(OAuth2User)를 반환한다.

  • OAuth2 제공자로부터 받은 사용자 정보를 Map 형태로 가져온다.

  • 네이버의 경우, 응답 데이터가 response라는 키 안에 감싸져 있으므로, 내부 데이터를 꺼내서 사용해야 한다.

OAuth2SuccessHandler

@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal();

        String email = oAuth2User.getEmail();
        String role = oAuth2User.getRole();

        String accessToken = tokenProvider.createAccessToken(email, role);
        String refreshToken = tokenProvider.createRefreshToken(email);

        // RefreshToken 저장
        refreshTokenService.saveRefreshToken(email, refreshToken);

        // Token을 HttpOnly 쿠키에 저장
        addAccessTokenCookie(response, accessToken);
        addRefreshTokenCookie(response, refreshToken);

        // 응답에 토큰 추가
        response.setHeader("Authorization", "Bearer " + accessToken);
        response.setHeader("Refresh-Token", refreshToken);

        super.onAuthenticationSuccess(request, response, authentication);
        
         /*
        // 추가 정보 입력 페이지로 리다이렉트
        // 입력 폼을 제공해야 됨
        // controller도 작성 -> email과 nickname 추출 후 나머지 정보 기입
        response.sendRedirect("/additional-info");
         */
    }

    private void addAccessTokenCookie(HttpServletResponse response, String accessToken) {
        Cookie cookie = new Cookie("accessToken", accessToken);
        cookie.setHttpOnly(true); // JavaScript에서 접근 불가능
        cookie.setSecure(false); // true -> HTTPS 환경에서만 전송
        cookie.setPath("/"); // 모든 경로에서 접근 가능
        cookie.setMaxAge((int) tokenProvider.getAccessTokenExpiration() / 1000);
        response.addCookie(cookie);
    }

    private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) {
        Cookie cookie = new Cookie("refreshToken", refreshToken);
        cookie.setHttpOnly(true); // JavaScript에서 접근 불가능
        cookie.setSecure(false); // true -> HTTPS 환경에서만 전송
        cookie.setPath("/"); // 모든 경로에서 접근 가능
        cookie.setMaxAge((int) tokenProvider.getRefreshTokenExpiration() / 1000);
        response.addCookie(cookie);
    }
}

이 클래스는 OAuth2 로그인 성공 시 실행되는 핸들러이다.

  • 로그인 성공 후 JWT 토큰을 발급
  • RefreshToken을 저장
  • 응답 헤더에 AccessTokenRefreshToken을 포함시킨다.
  • JWT 토큰을 HttpOnly 쿠키에 저장 (XSS 방지)

이후에 RefreshTokenRedis에 저장하는 것이 효율적이고,
Oauth2로 바로 로그인하는 것이 아니라 추가 정보를 입력할 수 있게 해주는 것이 좋다.

profile
공부하는 초보 개발자

0개의 댓글