https://github.com/lhoju0158

개발 끄적끄적

[Spring] Security - OAuth 2.0

lhoju0158 2025. 8. 1. 18:34

관련 코드 깃허브

https://github.com/lhoju0158/securityOAuth2.0

0. OAuth란

 

1. 구글 로그인 준비

- 구글 api console에 들어간다

- 새로운 프로젝트 생성

- OAuth 동의화면 - 프로젝트 구성 들어가서 설정 완료하기

- 개요 - OAuth 클라이언트 ID 만들기 들어가서 설정 완료하기

이 중 승인된 리디릭션 url은 다음과 같은 의미이다. 

로컬 로그인이 완료 되면 인증이 되었다는 코드를 돌려준다.

서버는 이 코드를 다시 받아서 해당 코드를 이용해 액세스 토큰을 요청한다.

액세스 토큰을 이용해 서버가 사용자 대신 구글 서버에 존재하는 사용자의 개인 정보에 접근할 수 있는 권한이 생긴다. 

 

승인된 리디랙션 url은 코드를 잡는 url이다. 

위 주소는 authclient라는 라이브러리를 사용하게 되면 고정이다. 

추가로 해당 url에 대한 컨트롤러 주소를 만들 필요 없다 (라이브러리가 하나씩 다 처리해주기 때문)

 

이후 client ID가 생성된다. 

 

이제 라이브러리를 설치하자. 

 

dependency에 다음과 같은 코드를 추가한다. 

 

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

 

application.yml에도 security 속성을 추가한다

 

security:
  oauth2:
    client:
      registration:
        google:
          client-id: [YOUR_ID]
          client-secret: [YOUR_SECRET]
          scope:
            - email
            - profile

 

id와 secret은 client ID에서 확인 가능하다

 

이후 서버를 실행시키고, 승인된 리디렉션 url로 이동하면

 

 

다음과 같이 404에러가 뜬다 현재 해당 주소에 아무것도 맵핑하지 않았기 때문이다. 이제 이걸 해결하자~ 

 

SecurityConfig에 oauth 관련 코드를 추가한다.

 

.oauth2Login(oauth -> oauth
        .loginPage("/loginForm")
    );

 

구글 Oauth2 로그인을 사용할 때 로그인 페이지를 loginForm으로 지정한다. 

 

이후 다시 접근하면

 

 

정상적으로 접근함을 할 수 있다. 

 

2. 구글 회원 프로필 정보 받아보기

이제 로그인 후처리를 해보자

 

정리를 하면 코드를 받고 (인증이 되었다는 의미) 이후 코드로 엑세스 토큰을 얻는다 (권한을 받는다는 의미)

이제 이 권한으로 사용자 프로필 정보를 가져온다 

 

이때 그 정보를 토대로 회원가입을 자동으로 진행시킨다 -> 이게 후처리

혹은 해당 정보가 회원가입을 하기엔 부족하다면 사용자에게 추가 정보를 입력 받아 회원가입을 진행한다 

 

근데 구글 로그인이 완료가 되면, 코드를 받는 것이 아니라. 엑세스토큰 + 사용자 프로필 정보를 받는다

(코드는 받는 것이 아니라 중간 단계)

 

            .oauth2Login(oauth->oauth
                .loginPage("/loginForm")
                .userInfoEndpoint(userInfo -> userInfo
                    .userService(null))
            );

 

securityConfig를 일단 다음과 같이 수정한다. 이후 DefaultOAuth2UserService를 상속받는 클래스를 만든다. null자리에 해당 class 변수를 넣는다. 

 

이제 후처리 클래스를 만든다. 

 

일단 어떤 정보가 userRequest에 담기는지 확인하자

 

package com.exam.securityex01.config.oauth;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;

@Service
public class PrinclpalOauth2UserService extends DefaultOAuth2UserService {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
        System.out.println("getClientRegistration = " + userRequest.getClientRegistration());
        System.out.println("getAccessToken = " + userRequest.getAccessToken());
        System.out.println("getAttributes = " + super.loadUser(userRequest).getAttributes());

        return super.loadUser(userRequest);
    }
}

 

다음과 같이 찍어보면

 

이렇게 나온다. getAttribute에서 확인할 수 있는 정보는

sub / name / given_name / family_name / picture / email / email_verified / locale

다음과 같다.

 

이 중 회원가입에 필요한 정보를 골라 사용하면 된다. 이때 만일 회원가입에 비밀번호가 필요하다면, 그건 아무거나 넣어도 무방하다. 해당 회원은 아이디, 비밀번호를 쳐서 들어온 회원이 아니기 때문에 굳이 넣을 필요 없다.

 

나는 username, password, email이 필요해 다음과 같이 적용하겠다

 

username = google_[sub]

password = [암호화 한 아무 단어]

email = [email]

role = "ROLE_USER"

 

이렇게 하면 해당 사용자가 OAuth로 로그인한 사용자인지, 그냥 로그인한 사용자인지 모른다. 그래서 user속성에 provider, providerId를 추가한다. 

 

3. Authentication 객체가 가질 수 있는 2가지 타입

정리)

구글 로그인 버튼 클릭 -> 구글 로그인 창이 뜬다 -> 로그인 완료 하면 코드를 oauth-client 라이브러리가 리턴 -> access token return받음 // 여기까지가 userRequest 정보

userRequest 정보로 -> 회원 프로필 받음 (이때 사용되는 함수가 loadUser)

 

테스트를 진행하자

 

    @GetMapping("/test/login")
    public @ResponseBody String testLogin(Authentication authentication){
        System.out.println("/test/login =============== ");
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        System.out.println("authentication : " + principalDetails.getUser());
        return "세션 정보 확인하기";
    }

 

이렇게 로그인을 진행한다. 만일 그냥 기본 로그인을 진행하면, 이때 getPrincipal의 return 타입은 object이다. 

 

authentication : User(id=1, username=user, password=$2a$10$uqT8yGB4S/RGkBexT8zU6O6BhKIOR3hr47EeAkkuACFq8E9xigyaS, email=user@gmail.com, role=ROLE_USER, provider=null, providerId=null, createDate=2025-07-31 18:57:40.746332)

 

이렇게 진행된다. 

 

위 코드를 정리하자.

Authentication에 존재하는 principal를 testLogin에 의존성 주입한다. return type이 object이기 때문에 downcasting받아서 principalDetails 에 할당한다. 이후 getUser를 출력한다. (principalDetails는 UserDetails를 상속받는다)

 

 

+) 의존성 주입 (DI)이란 두 객체 간의 관계를 결정지어주는 디자인 패턴이다. 인터페이스를 사이에 둬 런타임 시 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮춰준다. 

 

한 객체가 다른 객체를 사용할 때 의존성이 있다고 한다. (파라미터도 의존성 주입이다)

 

 

    @GetMapping("/test/login")
    public @ResponseBody String testLogin(
        Authentication authentication,
        @AuthenticationPrincipal UserDetails userDetails) {
        System.out.println("/test/login =============== ");
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        System.out.println("authentication : " + principalDetails.getUser());
        System.out.println("userDetails : " + userDetails);
        return "세션 정보 확인하기";
    }

 

위 코드로 변경 후 실행하면 @AuthenticationPrincipal이라는 annotation을 통해서 session 정보를 받아올 수 있음을 알 수 있다. 

 

이때 principalDetails는 UserDetails를 상속받기 때문에 다음과 같이 고칠 수 있다. 

 

    @GetMapping("/test/login")
    public @ResponseBody String testLogin(
        Authentication authentication,
        @AuthenticationPrincipal PrincipalDetails userDetails) {
        System.out.println("/test/login =============== ");
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        System.out.println("authentication : " + principalDetails.getUser());
        System.out.println("userDetails : " + userDetails.getUser());
        return "세션 정보 확인하기";
    }

 

정리)

userObject는 두가지 방법으로 찾을 수 있다. 

 

Authentication를 DI해서 downcasting을 한 후 user object를 찾을 수 있다.

혹은 @AuthenticationPrincipal annotation을 통해서도 user object를 찾을 수 있다.

 

이제 구글을 통해서 로그인을 진행하자. 

 

 

그러면 오류가 난다. (500 = 서버 오류)

 

        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();

 

여기서 난 오류임. casting exception이 발생했다. 즉 userDetails로 casting이 안된다는 말

 

    @GetMapping("/test/oauth/login")
    public @ResponseBody String testOAuthLogin(
        Authentication authentication,
        @AuthenticationPrincipal OAuth2User oauth) {
        System.out.println("/test/oauth/login =============== ");
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        System.out.println("authentication : " + oAuth2User.getAttributes());
        System.out.println("oauth2User : "+oauth.getAttributes());
        return "OAuth 세션 정보 확인하기";
    }

 

코드를 이렇게 수정한다 userDetails가 아닌 OAuth2User로 casting 한다. 위 정보는 userRequest에서 받은 사용자 정보와 동일하다. 

 

위와 같이 구글 로그인의 경우도 authentication 객체로 접근이 가능하고, annotation으로도 접근이 가능하다.

 

정리)

 

스프링 시큐리티는 자신만의 시큐리티 세션을 가지고 있다. (원래 session안에 security가 관리하는 session이 따로 존재한다)

시큐리티 세션에는 Authentication 객체만 들어갈 수 있다.

해당 객체를 Controller에서 DI할 수 있다. 

Authentication 객체는 두가지 타입을 가질 수 있다. 1. UserDetails (일반 로그인 시) 2. OAuth2User (OAuth 로그인 시)

 

발그림 지송..

 

우리가 필요할 때 Authentication 객체를 꺼내 써야한다. 근데 불편한 점이 있다. 일반적인 로그인과 OAuth로그인을 따로 파라미터를 넣어야 한다. -> Controller에서 뭘 적어야하나?

 

해결 방법

X라는 class를 만든 후 UserDetails, OAuth2User를 모두 implementation 하면 된다!

 

현재 PrincipalDetails는 UserDetails를 상속받고 있다. 그러니 PrincipalDetails에 OAuth2User 상속을 추가하자 

 

public class PrincipalDetails implements UserDetails, OAuth2User {

 

이렇게!

 

4. 구글 로그인 및 자동 회원가입 진행 완료

OAuth도 implements하면

 

    @Override
    public String getName() {
        return "";
    }

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

 

이렇게 두 개의 함수가 오버라이딩 되어야 한다. 

 

getAttributes는 위에서 사용한 함수다. 사용자 profile 정보 반환해준다.

getAttributes의 return값으로 User 정보를 채운다.

 

    private Map<String, Object> attributes;

 

principalDetails에 ID하고,

 

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

 

getAttributes를 다음과 같이 변경한다. 

 

    // 일반 로그인
    public PrincipalDetails(User user) {
        this.user = user;
    }
    // OAuth 로그인
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

 

다음과 같이 생성자를 오버로딩한다. 로그인마다 다른 생성자를 정의한다. 

 

이제 맞는 후처리를 진행하자.

 

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

        // 강제 회원 가입 진행
        String provider = userRequest.getClientRegistration().getRegistrationId(); // google
        String providerId = oAuth2User.getAttribute("sub");
        String username = provider + "_" + providerId;
        String password =bCryptPasswordEncoder.encode("비밀번호"); // oauth로 로그인 경우 굳이 필요 없음. 그냥 아무거나 진행
        String email = oAuth2User.getAttribute("email");
        String role = "ROLE_USER";

        User userEntity = userRepository.findByUsername(username);
        if(userEntity == null){
            // 강제로 회원가입 진행
            userEntity = User.builder()
                .username(username)
                .password(password)
                .email(email)
                .role(role)
                .provider(provider)
                .providerId(providerId)
                .build();
            userRepository.save(userEntity);
        }

        return new PrincipalDetails(userEntity,oAuth2User.getAttributes());
    }

 

 

 

이렇게 회원가입 진행할 수 있다. 이러면 security session에 attribute가 들어간다.

 

이제 controller에서 단일한 처리가 가능하다. 

 

@GetMapping("/user")
    public @ResponseBody String user(@AuthenticationPrincipal PrincipalDetails principalDetails){
        System.out.println("principal : " + principalDetails.getUser());
        return "user";
    }

 

 

이런식으로 처리 가능함

 

OAuth 로그인을 해도 PrincipalDetails로 받을 수 있음

일반 로그인을 해도 PrincipalDetails로 받을 수 있음 

 

개편함

 

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{

 

함수 종료 시 @AuthenticationPrincipal 어노테이션이 만들어진다. (서비스 생성 시 어노테이션이 만들어진다!)

 

5. 네이버 로그인 완료

OAuth2-Client 라이브러리는 provider를 제공한다. google, facebook, twitter가 포함된다. 

oauth에서 return해주는 getAttribute값이 모두 다르다. 그래서 각 사이트별로 구분이 필요하다.

 

네이버는 provider 제공을 안해줘서 별도 처리 필요

 

naver:
  client-id:[YOUR_ID]
  client-secret: [YOUR_SECRET]
  scope:
    - name
    - email
  client-name: Naver
  authorization-grant-type: authorization_code
  redirect-uri: http://localhost:8080/login/oauth2/code/naver

 

 

application_yml에 추가한다.

 

여기서 autorization-grant-type: authorization_code -> OAuth를 사용하는 방식 중 코드를 구현하는 방식을 선택

 

https://developers.naver.com/main/

 

NAVER Developers

네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음

developers.naver.com

 

 

application 등록으로 가 설정한다.

 

 

이후 client Id와 client secret을 받을 수 있다. 발급받은 id와 secret을 application.yml에 적는다.

 

이때 저장을하면 naver는 provider가 아니기 때문에 오류가 생긴다. 그래서 provider 등록이 필요하다.

 

provider:
  naver:
    authorization-uri: https://nid.naver.com/oauth2.0/authorize
    token-uri: https://nid.naver.com/oauth2.0/token
    user-info-uri: https://openapi.naver.com/v1/nid/me
    user-name-attribute: response # 회원 정보를 response라는 키값으로 json 형태로 naver가 리턴해줌

 

이것도 application.yml에 추가

해당 url은 네이버 개발 가이드에서 확인 가능하다.

 

 

이런식

 

이제 연결을 하자.

 

일단 provider를 여러개 생성한다.

 

이런식으로 OAuth2UserInfo interface를 선언한 수 각 사이트의 클래스를 생성했다. 이후 인터페이스를 오버라이딩 한다.

 

예시)

 

// OAuth2.0 제공자들 마다 응답해주는 속성값이 달라서 공통으로 만들어준다.
public interface OAuth2UserInfo {
    String getProviderId();
    String getProvider();
    String getEmail();
    String getName();
}

 

public class GoogleUserInfo implements OAuth2UserInfo{

    private Map<String, Object> attributes; // getAttributes

    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProviderId() {
        return attributes.get("sub").toString();
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getEmail() {
        return attributes.get("email").toString();
    }

    @Override
    public String getName() {
        return attributes.get("name").toString();
    }
}

 

네이버의 getAttribute를 살펴보자

 

 

이런식으로 response안에 정보가 존재한다. 이후 NaverUserInfo도 작성하자.

 

public class NaverUserInfo implements OAuth2UserInfo{
    private Map<String, Object> attributes; // getAttributes

    public NaverUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
    
    @Override
    public String getProviderId() {
        return attributes.get("id").toString();
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getEmail() {
        return attributes.get("email").toString();
    }

    @Override
    public String getName() {
        return attributes.get("name").toString();
    }
}

 

이후 회원가입 모듈도 수정한다.

 

public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
    OAuth2User oAuth2User = super.loadUser(userRequest);
    System.out.println("getAtttributes: " + oAuth2User.getAttributes());

    OAuth2UserInfo oAuth2UserInfo = null;
    // 강제 회원 가입 진행
    if(userRequest.getClientRegistration().getRegistrationId().equals("google")){
        System.out.println("구글 로그인 요청");
        oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
    }
    else if(userRequest.getClientRegistration().getRegistrationId().equals("naver")){
        System.out.println("네이버 로그인 요청");
        oAuth2UserInfo = new NaverUserInfo((Map)oAuth2User.getAttributes().get("response"));
    }
    else{
        System.out.println("지원하지 않는 페이지 입니다.");
    }
    String provider = oAuth2UserInfo.getProvider();
    String providerId = oAuth2UserInfo.getProviderId();
    String username = provider + "_" + providerId;
    String password =bCryptPasswordEncoder.encode("비밀번호"); // oauth로 로그인 경우 굳이 필요 없음. 그냥 아무거나 진행
    String email = oAuth2UserInfo.getEmail();
    String role = "ROLE_USER";

 

 

<a href = "/oauth2/authorization/naver">네이버 로그인</a>

 

네이버 로그인 href를 생성하고 (이때 autherization-uri로 자동 연결된다.)

 

로그인을 진행하면 네이버도 정상정으로 작동한다. 

+) 참고 자료

https://codevang.tistory.com/273

 

스프링 Security_로그인_Principal 객체 [7/9]

- Develop OS : Windows10 Ent, 64bit - WEB/WAS Server : Tomcat v9.0 - DBMS : MySQL 5.7.29 for Linux (Docker) - Language : JAVA 1.8 (JDK 1.8) - Framwork : Spring 3.1.1 Release - Build Tool : Maven 3.6.3 - ORM : Mybatis 3.2.8 커스터마이징 순서대로

codevang.tistory.com